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() {
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) {