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:
Moheeb Zara
2026-05-16 18:52:53 -07:00
parent e439b1130f
commit ddf6717d1b
3 changed files with 232 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest'
import {
computeExpiry,
isCertificationActive,
certificationStatus,
EXPIRING_SOON_DAYS,
} from '@/lib/certifications-logic'
describe('computeExpiry', () => {
it('returns null when the cert never expires', () => {
expect(computeExpiry('2026-05-16T00:00:00.000Z', null)).toBeNull()
expect(computeExpiry('2026-05-16T00:00:00.000Z', undefined)).toBeNull()
})
it('returns null for non-positive or non-integer validity', () => {
expect(computeExpiry('2026-05-16T00:00:00.000Z', 0)).toBeNull()
expect(computeExpiry('2026-05-16T00:00:00.000Z', -3)).toBeNull()
expect(computeExpiry('2026-05-16T00:00:00.000Z', 1.5)).toBeNull()
})
it('returns null for an unparseable grant date', () => {
expect(computeExpiry('not-a-date', 12)).toBeNull()
})
it('adds whole months', () => {
expect(computeExpiry('2026-01-15T00:00:00.000Z', 1)).toBe('2026-02-15T00:00:00.000Z')
expect(computeExpiry('2026-01-15T00:00:00.000Z', 12)).toBe('2027-01-15T00:00:00.000Z')
})
it('rolls the year over correctly', () => {
expect(computeExpiry('2026-11-10T00:00:00.000Z', 3)).toBe('2027-02-10T00:00:00.000Z')
})
it('clamps the day when the target month is shorter (Jan 31 + 1mo)', () => {
// 2026 is not a leap year -> February has 28 days.
expect(computeExpiry('2026-01-31T12:00:00.000Z', 1)).toBe('2026-02-28T12:00:00.000Z')
})
it('clamps to Feb 29 in a leap year', () => {
expect(computeExpiry('2028-01-31T00:00:00.000Z', 1)).toBe('2028-02-29T00:00:00.000Z')
})
})
describe('isCertificationActive', () => {
const now = '2026-05-16T00:00:00.000Z'
it('is false when revoked, regardless of expiry', () => {
expect(isCertificationActive({ revoked_at: now, expires_at: null }, now)).toBe(false)
expect(isCertificationActive({ revoked_at: now, expires_at: '2099-01-01T00:00:00.000Z' }, now)).toBe(false)
})
it('is true when not revoked and no expiry', () => {
expect(isCertificationActive({ revoked_at: null, expires_at: null }, now)).toBe(true)
})
it('respects the expiry boundary', () => {
expect(isCertificationActive({ expires_at: '2026-05-17T00:00:00.000Z' }, now)).toBe(true)
expect(isCertificationActive({ expires_at: '2026-05-15T00:00:00.000Z' }, now)).toBe(false)
})
it('treats an unparseable expiry as non-expiring', () => {
expect(isCertificationActive({ expires_at: 'garbage' }, now)).toBe(true)
})
})
describe('certificationStatus', () => {
const now = new Date('2026-05-16T00:00:00.000Z')
it('revoked wins even past expiry', () => {
expect(
certificationStatus({ revoked_at: '2026-01-01T00:00:00.000Z', expires_at: '2025-01-01T00:00:00.000Z' }, now),
).toBe('revoked')
})
it('no expiry is active', () => {
expect(certificationStatus({ expires_at: null }, now)).toBe('active')
})
it('past expiry is expired', () => {
expect(certificationStatus({ expires_at: '2026-05-15T00:00:00.000Z' }, now)).toBe('expired')
})
it('within the soon window is expiring_soon', () => {
const soon = new Date(now.getTime() + (EXPIRING_SOON_DAYS - 1) * 86400000).toISOString()
expect(certificationStatus({ expires_at: soon }, now)).toBe('expiring_soon')
})
it('comfortably in the future is active', () => {
const far = new Date(now.getTime() + (EXPIRING_SOON_DAYS + 10) * 86400000).toISOString()
expect(certificationStatus({ expires_at: far }, now)).toBe('active')
})
})
+77
View File
@@ -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',
}
+63
View File
@@ -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')