diff --git a/__tests__/certifications.test.ts b/__tests__/certifications.test.ts new file mode 100644 index 0000000..7853e7a --- /dev/null +++ b/__tests__/certifications.test.ts @@ -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') + }) +}) diff --git a/lib/certifications-logic.ts b/lib/certifications-logic.ts new file mode 100644 index 0000000..d3ff030 --- /dev/null +++ b/lib/certifications-logic.ts @@ -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 = { + active: 'Active', + expiring_soon: 'Expiring soon', + expired: 'Expired', + revoked: 'Revoked', +} diff --git a/lib/validations.ts b/lib/validations.ts index 70ce0a3..918664d 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -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')