diff --git a/app/(pages)/about/page.tsx b/app/(pages)/about/page.tsx index a7847ba8a..1db024bfb 100644 --- a/app/(pages)/about/page.tsx +++ b/app/(pages)/about/page.tsx @@ -143,13 +143,6 @@ export default function AboutPage() {

{COMPANY_INFO.name}

-
-

- Registered Address -

-

{COMPANY_INFO.address}

-
-

Contact Email diff --git a/app/(pages)/constants.ts b/app/(pages)/constants.ts index d46c27814..af0d8137e 100644 --- a/app/(pages)/constants.ts +++ b/app/(pages)/constants.ts @@ -1,8 +1,9 @@ // Company Information - Update these values with your actual business details export const COMPANY_INFO = { - name: "[COMPANY NAME]", - address: "[ADDRESS]", + name: "Practitionist", + // TODO: real contact email before launch email: "[EMAIL]", + // TODO: real contact email before launch supportEmail: "[SUPPORT_EMAIL]", phone: "[PHONE]", jurisdiction: "[JURISDICTION]", diff --git a/app/(pages)/contactus/page.tsx b/app/(pages)/contactus/page.tsx index a70c2cfd8..d0d5c8837 100644 --- a/app/(pages)/contactus/page.tsx +++ b/app/(pages)/contactus/page.tsx @@ -12,7 +12,7 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { Mail, MapPin, Phone, Clock, MessageSquare } from "lucide-react"; +import { Mail, Phone, Clock, MessageSquare } from "lucide-react"; import { COMPANY_INFO, PAGE_META, @@ -51,23 +51,6 @@ export default function ContactUsPage() { - {/* Company Address */} -

-
- -
-
-

Address

-

- {COMPANY_INFO.name} -
- {COMPANY_INFO.address} -

-
-
- - - {/* Email */}
diff --git a/app/(pages)/privacy/page.tsx b/app/(pages)/privacy/page.tsx index f418ebd27..22679cd6d 100644 --- a/app/(pages)/privacy/page.tsx +++ b/app/(pages)/privacy/page.tsx @@ -478,9 +478,6 @@ export default function PrivacyPolicyPage() {

Company Name: {COMPANY_INFO.name}

-

- Address: {COMPANY_INFO.address} -

Email:{" "} Company Name: {COMPANY_INFO.name}

-

- Address: {COMPANY_INFO.address} -

Email:{" "} Company Name: {COMPANY_INFO.name}

-

- Address: {COMPANY_INFO.address} -

Email:{" "} { if (refCode) setPendingReferral(refCode); - else clearPendingReferral(); }, [refCode]); // Show loading while checking session status (fallback for when middleware doesn't catch) diff --git a/lib/collaborators/service.ts b/lib/collaborators/service.ts index 827718936..b6c0c5619 100644 --- a/lib/collaborators/service.ts +++ b/lib/collaborators/service.ts @@ -69,6 +69,28 @@ function asPlanRole(planType: PlanType, role: string): CollaboratorRole | null { /** * Invite a collaborator to a webinar or class plan. */ +// #768 lockdown #12 — capability booleans, set from invite input. Default +// false so an unspecified permission is never silently granted. +// Enforced: canSeeAttendees (participant-roster GET). +// TODO #768 — enforce canApprovePayment / canViewAnalytics / canEditEvent +// once collaborator-facing payment-approval, analytics, and event-edit +// surfaces exist; today they have no endpoint to gate, so only the SET lands. +export interface CollaboratorPermissions { + canApprovePayment?: boolean; + canViewAnalytics?: boolean; + canEditEvent?: boolean; + canSeeAttendees?: boolean; +} + +function normalizePermissions(permissions?: CollaboratorPermissions) { + return { + canApprovePayment: permissions?.canApprovePayment ?? false, + canViewAnalytics: permissions?.canViewAnalytics ?? false, + canEditEvent: permissions?.canEditEvent ?? false, + canSeeAttendees: permissions?.canSeeAttendees ?? false, + }; +} + export async function inviteCollaborator( planType: PlanType, planId: string, @@ -76,6 +98,7 @@ export async function inviteCollaborator( role: string, revenueSharePercentage: number, invitedById: string, + permissions?: CollaboratorPermissions, ): Promise { // Validate percentage range if (revenueSharePercentage <= 0 || revenueSharePercentage > 90) { @@ -85,6 +108,8 @@ export async function inviteCollaborator( const planRole = asPlanRole(planType, role); if (!planRole) return null; + const perms = normalizePermissions(permissions); + // Verify the invited consultant profile exists before creating a collaborator record. // Without this check, a stale or fabricated consultantProfileId creates an orphaned row. const inviteeProfile = await prisma.consultantProfile.findUnique({ @@ -125,6 +150,7 @@ export async function inviteCollaborator( status: "PENDING", invitedById, respondedAt: null, + ...perms, }, }); } @@ -140,6 +166,7 @@ export async function inviteCollaborator( revenueShareBps: pctToBps(revenueSharePercentage), status: "PENDING", invitedById, + ...perms, }, }); }, diff --git a/lib/compliance/dpdp.ts b/lib/compliance/dpdp.ts index df181a60a..480d74a29 100644 --- a/lib/compliance/dpdp.ts +++ b/lib/compliance/dpdp.ts @@ -1,9 +1,12 @@ /** - * DPDP (Digital Personal Data Protection Act, 2023) — INDIA COMPLIANCE STUB. - * - * STATUS: stub. `recordConsent` creates a ConsentArtifact row with a mock - * hash; `checkConsent` returns `true` unconditionally. Live impl lands in - * a follow-up PR. + * DPDP (Digital Personal Data Protection Act, 2023) — INDIA COMPLIANCE. + * + * STATUS: consent primitives are LIVE. `recordConsent` writes a ConsentArtifact + * row with a real SHA-256 payload hash; `checkConsent` is fail-closed — it + * returns `true` only when a non-withdrawn, non-expired artifact exists for the + * (user, purpose) pair, else `false`. The substantive operator obligations + * below (Consent Manager registration, breach reporting, rights fulfilment) + * remain follow-up work. * * ───────────────────────────────────────────────────────────────────────── * LIVE IMPLEMENTATION REQUIREMENTS (follow-up PR) diff --git a/lib/novu/service.ts b/lib/novu/service.ts index 98877f6f1..1053e8771 100644 --- a/lib/novu/service.ts +++ b/lib/novu/service.ts @@ -4,6 +4,7 @@ * Non-throwing: logs errors and returns success/failure status. * Pattern follows lib/email.ts (graceful degradation). */ +import { createHash } from "node:crypto"; import * as Sentry from "@sentry/nextjs"; import { getNovuClient, isNovuConfigured } from "./client"; import { @@ -51,13 +52,51 @@ interface TriggerResult { error?: Error | string; } +// Unconfigured Novu in a deployed env means notifications silently vanish — +// a console.warn nobody reads is not enough. Local dev stays console-only. +function reportNotConfigured(workflowId: string): void { + console.warn(`[Novu] Not configured. Skipped workflow: ${workflowId}`); + if (process.env.NODE_ENV === "production") { + Sentry.captureMessage(`[Novu] Not configured — dropped ${workflowId}`, { + level: "warning", + tags: { subsystem: "novu" }, + }); + } +} + +// Deterministic transactionId so app-level retries can't double-notify: Novu +// rejects a repeated transactionId. Derived from recipient(s) + workflow + +// canonical payload (the payloads carry the entity ids). `dedupeKey` lets a +// caller that legitimately re-sends an identical payload (e.g. 24h vs 1h +// appointment reminders) disambiguate the sends. +function deriveTransactionId( + workflowId: string, + recipients: string | string[], + payload: NovuPayload, + dedupeKey?: string, +): string { + const canonicalPayload = JSON.stringify( + Object.fromEntries( + Object.entries(payload).sort(([a], [b]) => a.localeCompare(b)), + ), + ); + const recipientKey = Array.isArray(recipients) + ? [...recipients].sort().join(",") + : recipients; + const hash = createHash("sha256") + .update(`${workflowId}|${recipientKey}|${dedupeKey ?? canonicalPayload}`) + .digest("hex"); + return `${workflowId}:${hash.slice(0, 32)}`; +} + async function triggerWorkflow( workflowId: string, subscriberId: string, payload: T, + dedupeKey?: string, ): Promise { if (!isNovuConfigured()) { - console.warn(`[Novu] Not configured. Skipped workflow: ${workflowId}`); + reportNotConfigured(workflowId); return { success: false, error: "Novu not configured" }; } @@ -67,6 +106,12 @@ async function triggerWorkflow( workflowId, to: subscriberId, payload, + transactionId: deriveTransactionId( + workflowId, + subscriberId, + payload, + dedupeKey, + ), }); console.log(`[Novu] Triggered ${workflowId} for ${subscriberId}`); return { success: true }; @@ -91,9 +136,10 @@ async function triggerForMultiple( workflowId: string, userIds: string[], payload: T, + dedupeKey?: string, ): Promise { if (!isNovuConfigured()) { - console.warn(`[Novu] Not configured. Skipped workflow: ${workflowId}`); + reportNotConfigured(workflowId); return userIds.map(() => ({ success: false, error: "Novu not configured" as const, @@ -102,7 +148,7 @@ async function triggerForMultiple( if (userIds.length === 0) return []; if (userIds.length === 1) - return [await triggerWorkflow(workflowId, userIds[0], payload)]; + return [await triggerWorkflow(workflowId, userIds[0], payload, dedupeKey)]; const BATCH_SIZE = 100; const results: TriggerResult[] = []; @@ -115,6 +161,12 @@ async function triggerForMultiple( workflowId, to: batch, payload, + transactionId: deriveTransactionId( + workflowId, + batch, + payload, + dedupeKey, + ), }); console.log( `[Novu] Triggered ${workflowId} for ${batch.length} subscribers`, @@ -142,7 +194,7 @@ async function triggerBroadcastWorkflow( payload: T, ): Promise { if (!isNovuConfigured()) { - console.warn(`[Novu] Not configured. Skipped broadcast: ${workflowId}`); + reportNotConfigured(workflowId); return { success: false, error: "Novu not configured" }; } @@ -211,14 +263,18 @@ export async function notifyAppointmentCompleted( ); } +// `dedupeKey` (appointment + window) keeps the 1h reminder from being +// swallowed as a duplicate of the 24h one — their payloads are identical. export async function notifyAppointmentReminder( userIds: string[], payload: AppointmentPayload, + dedupeKey?: string, ) { return triggerForMultiple( NOVU_WORKFLOWS.APPOINTMENT_REMINDER, userIds, payload, + dedupeKey, ); } diff --git a/prisma/sql/check-constraints.sql b/prisma/sql/check-constraints.sql index 7a38c1e68..ff445ba21 100644 --- a/prisma/sql/check-constraints.sql +++ b/prisma/sql/check-constraints.sql @@ -162,3 +162,13 @@ ALTER TABLE "ConsultantPayout" DROP CONSTRAINT IF EXISTS "consultant_payout_tds_ -- SPLIT ALTER TABLE "ConsultantPayout" ADD CONSTRAINT "consultant_payout_tds_fy_format" CHECK ("tdsFinancialYear" IS NULL OR "tdsFinancialYear" ~ '^[0-9]{4}-[0-9]{2}$'); + +-- SPLIT +-- #784 — a Collaborator references exactly one plan: a webinar XOR a class. +-- The app-level backstop is assertCollaboratorPlanXor in +-- lib/collaborators/service.ts; this DB CHECK is the last line. Exactly one of +-- the two FKs is non-NULL <=> exactly one IS NULL, which `<>` expresses. +ALTER TABLE "Collaborator" DROP CONSTRAINT IF EXISTS "collaborator_plan_xor"; +-- SPLIT +ALTER TABLE "Collaborator" ADD CONSTRAINT "collaborator_plan_xor" + CHECK (("webinarPlanId" IS NULL) <> ("classPlanId" IS NULL)); diff --git a/schemas/collaborators.ts b/schemas/collaborators.ts index f910c58cb..ae7de62d8 100644 --- a/schemas/collaborators.ts +++ b/schemas/collaborators.ts @@ -20,14 +20,29 @@ export const CLASS_COLLABORATOR_ROLES = [ export const WebinarCollaboratorRoleEnum = z.enum(WEBINAR_COLLABORATOR_ROLES); export const ClassCollaboratorRoleEnum = z.enum(CLASS_COLLABORATOR_ROLES); -export const inviteCollaboratorSchema = z.object({ - consultantProfileId: z.string().min(1, "Consultant profile ID is required"), - revenueSharePercentage: z - .number({ required_error: "Revenue share percentage is required" }) - .gt(0, "Revenue share percentage must be greater than 0") - .lte(90, "Revenue share percentage cannot exceed 90"), +// #768 lockdown #12 — typed permission booleans set at invite time. Default +// false (least privilege); the owner opts each capability in per collaborator. +export const collaboratorPermissionsSchema = z.object({ + canApprovePayment: z.boolean().optional().default(false), + canViewAnalytics: z.boolean().optional().default(false), + canEditEvent: z.boolean().optional().default(false), + canSeeAttendees: z.boolean().optional().default(false), }); +export type CollaboratorPermissions = z.infer< + typeof collaboratorPermissionsSchema +>; + +export const inviteCollaboratorSchema = z + .object({ + consultantProfileId: z.string().min(1, "Consultant profile ID is required"), + revenueSharePercentage: z + .number({ required_error: "Revenue share percentage is required" }) + .gt(0, "Revenue share percentage must be greater than 0") + .lte(90, "Revenue share percentage cannot exceed 90"), + }) + .merge(collaboratorPermissionsSchema); + export const inviteWebinarCollaboratorSchema = inviteCollaboratorSchema.extend({ role: WebinarCollaboratorRoleEnum, }); diff --git a/scripts/appointments/send-appointment-reminders.ts b/scripts/appointments/send-appointment-reminders.ts index dae19be6c..236913052 100644 --- a/scripts/appointments/send-appointment-reminders.ts +++ b/scripts/appointments/send-appointment-reminders.ts @@ -210,14 +210,20 @@ async function sendRemindersForWindow(window: { const baseUrl = getAppUrl(); - await notifyAppointmentReminder(uniqueUserIds, { - appointmentType, - consultantName, - consulteeName, - planTitle, - dateTime: slot.startsAt.toISOString(), - dashboardUrl: `${baseUrl}/dashboard`, - }); + await notifyAppointmentReminder( + uniqueUserIds, + { + appointmentType, + consultantName, + consulteeName, + planTitle, + dateTime: slot.startsAt.toISOString(), + dashboardUrl: `${baseUrl}/dashboard`, + }, + // 24h and 1h payloads are identical — key the Novu transactionId by + // window so the second reminder isn't deduped away. + `${apt.id}:${window.label}`, + ); sent++; } catch (error) {