feat(certifications): pure expiry/status logic + Zod schemas
certifications-logic.ts: computeExpiry (month math with end-of-month clamping and year rollover), isCertificationActive, certificationStatus (revoked > expired > expiring_soon > active). Dependency-free and unit-tested (16 cases incl. Jan-31 and leap-year boundaries). validations.ts: create/update/grant/revoke/renew + id/list schemas. grant's expires_at is an optional override (empty -> null); validity is a positive integer count of months.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// Pure, dependency-free decision logic for the certifications feature.
|
||||
// No Supabase, no React, no Next imports. Everything here is unit-tested
|
||||
// directly so the expiry/active rules cannot silently drift.
|
||||
|
||||
export const EXPIRING_SOON_DAYS = 30
|
||||
|
||||
export type CertStatus = 'active' | 'expiring_soon' | 'expired' | 'revoked'
|
||||
|
||||
// Add a whole number of months to an ISO timestamp, clamping the day so that
|
||||
// e.g. Jan 31 + 1 month is the last day of February rather than rolling into
|
||||
// March. Returns an ISO string, or null when the cert type never expires
|
||||
// (validityMonths null/undefined) or the inputs are unusable.
|
||||
export function computeExpiry(
|
||||
grantedAtISO: string,
|
||||
validityMonths: number | null | undefined,
|
||||
): string | null {
|
||||
if (validityMonths == null) return null
|
||||
if (!Number.isInteger(validityMonths) || validityMonths <= 0) return null
|
||||
const granted = new Date(grantedAtISO)
|
||||
if (Number.isNaN(granted.getTime())) return null
|
||||
|
||||
const y = granted.getUTCFullYear()
|
||||
const m = granted.getUTCMonth()
|
||||
const d = granted.getUTCDate()
|
||||
|
||||
const targetMonthIndex = m + validityMonths
|
||||
const targetYear = y + Math.floor(targetMonthIndex / 12)
|
||||
const targetMonth = ((targetMonthIndex % 12) + 12) % 12
|
||||
|
||||
// Last day of the target month (day 0 of the next month).
|
||||
const lastDayOfTarget = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate()
|
||||
const day = Math.min(d, lastDayOfTarget)
|
||||
|
||||
const result = new Date(granted.getTime())
|
||||
result.setUTCFullYear(targetYear, targetMonth, day)
|
||||
return result.toISOString()
|
||||
}
|
||||
|
||||
type GrantTimes = {
|
||||
revoked_at?: string | null
|
||||
expires_at?: string | null
|
||||
}
|
||||
|
||||
function nowMs(now?: Date | string): number {
|
||||
if (now == null) return Date.now()
|
||||
const t = now instanceof Date ? now.getTime() : new Date(now).getTime()
|
||||
return Number.isNaN(t) ? Date.now() : t
|
||||
}
|
||||
|
||||
export function isCertificationActive(g: GrantTimes, now?: Date | string): boolean {
|
||||
if (g.revoked_at) return false
|
||||
if (!g.expires_at) return true
|
||||
const exp = new Date(g.expires_at).getTime()
|
||||
if (Number.isNaN(exp)) return true
|
||||
return exp > nowMs(now)
|
||||
}
|
||||
|
||||
// Single source of truth for the badge a grant should show. Revoked always
|
||||
// wins (even if also past expiry); then expired; then expiring-soon within
|
||||
// EXPIRING_SOON_DAYS; otherwise active.
|
||||
export function certificationStatus(g: GrantTimes, now?: Date | string): CertStatus {
|
||||
if (g.revoked_at) return 'revoked'
|
||||
if (!g.expires_at) return 'active'
|
||||
const exp = new Date(g.expires_at).getTime()
|
||||
if (Number.isNaN(exp)) return 'active'
|
||||
const t = nowMs(now)
|
||||
if (exp <= t) return 'expired'
|
||||
if (exp - t <= EXPIRING_SOON_DAYS * 24 * 60 * 60 * 1000) return 'expiring_soon'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
export const CERT_STATUS_LABEL: Record<CertStatus, string> = {
|
||||
active: 'Active',
|
||||
expiring_soon: 'Expiring soon',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked',
|
||||
}
|
||||
@@ -678,6 +678,69 @@ export const getPublicFormSchema = z.object({
|
||||
slug: formSlug,
|
||||
})
|
||||
|
||||
// ─── Certifications ──────────────────────────────────────────────────────────
|
||||
|
||||
const certificationName = z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(200, 'Name must be 200 characters or fewer')
|
||||
|
||||
// validity_months: null/omitted = never expires. When set it must be a
|
||||
// positive integer (months added to grant time to compute expiry).
|
||||
const validityMonths = z
|
||||
.number()
|
||||
.int('Validity must be a whole number of months')
|
||||
.positive('Validity must be at least 1 month')
|
||||
.max(1200, 'Validity is unreasonably large')
|
||||
.optional()
|
||||
.nullable()
|
||||
|
||||
export const createCertificationSchema = z.object({
|
||||
name: certificationName,
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
validity_months: validityMonths,
|
||||
})
|
||||
|
||||
export const updateCertificationSchema = z.object({
|
||||
certificationId: z.string().uuid('Invalid certification ID'),
|
||||
name: certificationName.optional(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
validity_months: validityMonths,
|
||||
is_active: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const certificationIdSchema = z.object({
|
||||
certificationId: z.string().uuid('Invalid certification ID'),
|
||||
})
|
||||
|
||||
// Award a cert to a member. expires_at is optional: when omitted the action
|
||||
// computes it from the cert's validity_months; when provided it overrides
|
||||
// (e.g. an externally dated certificate). Empty string -> null (no expiry).
|
||||
export const grantCertificationSchema = z.object({
|
||||
memberId: z.string().uuid('Invalid member ID'),
|
||||
certificationId: z.string().uuid('Invalid certification ID'),
|
||||
note: z.string().max(2000).optional().nullable(),
|
||||
expires_at: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform(v => (v && v.trim() !== '' ? v : null)),
|
||||
})
|
||||
|
||||
export const revokeCertificationSchema = z.object({
|
||||
memberCertificationId: z.string().uuid('Invalid grant ID'),
|
||||
reason: z.string().max(500).optional().nullable(),
|
||||
})
|
||||
|
||||
export const renewCertificationSchema = z.object({
|
||||
memberCertificationId: z.string().uuid('Invalid grant ID'),
|
||||
note: z.string().max(2000).optional().nullable(),
|
||||
})
|
||||
|
||||
export const listMemberCertificationsSchema = z.object({
|
||||
memberId: z.string().uuid('Invalid member ID'),
|
||||
})
|
||||
|
||||
// ─── Generic ID schemas ──────────────────────────────────────────────────────
|
||||
|
||||
export const uuidSchema = z.string().uuid('Invalid ID format')
|
||||
|
||||
Reference in New Issue
Block a user