Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions app/(pages)/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,6 @@ export default function AboutPage() {
<p>{COMPANY_INFO.name}</p>
</div>
<Separator />
<div>
<p className="text-sm font-semibold text-muted-foreground">
Registered Address
</p>
<p>{COMPANY_INFO.address}</p>
</div>
<Separator />
<div>
<p className="text-sm font-semibold text-muted-foreground">
Contact Email
Expand Down
5 changes: 3 additions & 2 deletions app/(pages)/constants.ts
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 4 in app/(pages)/constants.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AZ9SeMYwKu499Cc-IgQd&open=AZ9SeMYwKu499Cc-IgQd&pullRequest=989
email: "[EMAIL]",
// TODO: real contact email before launch

Check warning on line 6 in app/(pages)/constants.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AZ9SeMYwKu499Cc-IgQe&open=AZ9SeMYwKu499Cc-IgQe&pullRequest=989
supportEmail: "[SUPPORT_EMAIL]",
phone: "[PHONE]",
jurisdiction: "[JURISDICTION]",
Expand Down
19 changes: 1 addition & 18 deletions app/(pages)/contactus/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -51,23 +51,6 @@ export default function ContactUsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Company Address */}
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<MapPin className="h-5 w-5 text-foreground" />
</div>
<div>
<h3 className="font-semibold mb-1">Address</h3>
<p className="text-sm text-muted-foreground">
{COMPANY_INFO.name}
<br />
{COMPANY_INFO.address}
</p>
</div>
</div>

<Separator />

{/* Email */}
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-muted flex items-center justify-center">
Expand Down
3 changes: 0 additions & 3 deletions app/(pages)/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -478,9 +478,6 @@ export default function PrivacyPolicyPage() {
<p>
<strong>Company Name:</strong> {COMPANY_INFO.name}
</p>
<p>
<strong>Address:</strong> {COMPANY_INFO.address}
</p>
<p>
<strong>Email:</strong>{" "}
<a
Expand Down
3 changes: 0 additions & 3 deletions app/(pages)/refund/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -685,9 +685,6 @@ export default function RefundPolicyPage() {
<p>
<strong>Company Name:</strong> {COMPANY_INFO.name}
</p>
<p>
<strong>Address:</strong> {COMPANY_INFO.address}
</p>
<p>
<strong>Email:</strong>{" "}
<a
Expand Down
3 changes: 0 additions & 3 deletions app/(pages)/terms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -622,9 +622,6 @@ export default function TermsPage() {
<p>
<strong>Company Name:</strong> {COMPANY_INFO.name}
</p>
<p>
<strong>Address:</strong> {COMPANY_INFO.address}
</p>
<p>
<strong>Email:</strong>{" "}
<a
Expand Down
11 changes: 10 additions & 1 deletion app/api/collaborations/class/[planId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,15 @@ export async function POST(
);
}

const { consultantProfileId, role, revenueSharePercentage } = parsed.data;
const {
consultantProfileId,
role,
revenueSharePercentage,
canApprovePayment,
canViewAnalytics,
canEditEvent,
canSeeAttendees,
} = parsed.data;

if (consultantProfileId === ownerProfile.id) {
return NextResponse.json(
Expand Down Expand Up @@ -116,6 +124,7 @@ export async function POST(
role,
revenueSharePercentage,
ownerProfile.id,
{ canApprovePayment, canViewAnalytics, canEditEvent, canSeeAttendees },
);

if (!collab) {
Expand Down
11 changes: 10 additions & 1 deletion app/api/collaborations/webinar/[planId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,15 @@ export async function POST(
);
}

const { consultantProfileId, role, revenueSharePercentage } = parsed.data;
const {
consultantProfileId,
role,
revenueSharePercentage,
canApprovePayment,
canViewAnalytics,
canEditEvent,
canSeeAttendees,
} = parsed.data;

if (consultantProfileId === ownerProfile.id) {
return NextResponse.json(
Expand Down Expand Up @@ -117,6 +125,7 @@ export async function POST(
role,
revenueSharePercentage,
ownerProfile.id,
{ canApprovePayment, canViewAnalytics, canEditEvent, canSeeAttendees },
);

if (!collab) {
Expand Down
25 changes: 20 additions & 5 deletions app/api/participants/class/[classId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,31 @@ export async function GET(

try {
const { classId } = await params;
// Non-privileged users can only view participants for classes they own as consultant
const classEvent = await prisma.class.findUnique({
// Non-privileged users can view the roster if they own the plan OR are an
// accepted collaborator granted canSeeAttendees (#768). Everyone else 404s.
const classEvent = await prisma.class.findFirst({
where: {
id: classId,
...(isPrivileged(session.user.role)
? {}
: {
classPlan: {
consultantProfileId:
session.user.consultantProfileId ?? "__none__",
OR: [
{
consultantProfileId:
session.user.consultantProfileId ?? "__none__",
},
{
collaborators: {
some: {
consultantProfileId:
session.user.consultantProfileId ?? "__none__",
status: "ACCEPTED",
canSeeAttendees: true,
},
},
},
],
},
}),
},
Expand Down Expand Up @@ -145,7 +160,7 @@ export async function DELETE(
// Ownership check only — the old shape loaded the entire roster
// (every appointment × every slot × every full User row) just to find
// the one participant being removed.
const classEvent = await prisma.class.findUnique({
const classEvent = await prisma.class.findFirst({
where: {
id: classId,
...(isPrivileged(session.user.role)
Expand Down
25 changes: 20 additions & 5 deletions app/api/participants/webinar/[webinarId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,31 @@ export async function GET(

try {
const { webinarId } = await params;
// Non-privileged users can only view participants for webinars they own as consultant
const webinarEvent = await prisma.webinar.findUnique({
// Non-privileged users can view the roster if they own the plan OR are an
// accepted collaborator granted canSeeAttendees (#768). Everyone else 404s.
const webinarEvent = await prisma.webinar.findFirst({
where: {
id: webinarId,
...(isPrivileged(session.user.role)
? {}
: {
webinarPlan: {
consultantProfileId:
session.user.consultantProfileId ?? "__none__",
OR: [
{
consultantProfileId:
session.user.consultantProfileId ?? "__none__",
},
{
collaborators: {
some: {
consultantProfileId:
session.user.consultantProfileId ?? "__none__",
status: "ACCEPTED",
canSeeAttendees: true,
},
},
},
],
},
}),
},
Expand Down Expand Up @@ -140,7 +155,7 @@ export async function DELETE(
// Ownership check only — the old shape loaded the entire roster
// (every slot × every full User row) just to find the one participant
// being removed.
const webinarEvent = await prisma.webinar.findUnique({
const webinarEvent = await prisma.webinar.findFirst({
where: {
id: webinarId,
...(isPrivileged(session.user.role)
Expand Down
7 changes: 5 additions & 2 deletions app/api/staff/support-tickets/[ticketId]/responses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,11 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
// Update ticket status to IN_PROGRESS if it was OPEN
// Only update if this is not an internal note
if (ticket.status === "OPEN" && !validatedData.isInternal) {
await prisma.supportTicket.update({
where: { id: ticketId },
// Status-guarded CAS: a concurrent staff edit that already moved the
// ticket off OPEN must not be clobbered back. updateMany is a no-op
// (count 0) when the guard misses, so the loser silently yields.
await prisma.supportTicket.updateMany({
where: { id: ticketId, status: "OPEN" },
data: {
status: "IN_PROGRESS",
// Auto-assign to responding staff if not already assigned
Expand Down
28 changes: 28 additions & 0 deletions app/api/user/support-tickets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,34 @@ export async function POST(req: NextRequest) {
);
}

// Dedup: a payment-linked ticket reuses any still-open ticket the user
// already filed for the same payment. Kept a runtime check (not a schema
// unique) — a payment can legitimately spawn a second ticket once the
// first is RESOLVED/CLOSED, so uniqueness is scoped to open state.
if (validatedData.paymentId) {
const existing = await prisma.supportTicket.findFirst({
where: {
paymentId: validatedData.paymentId,
userId: session.user.id,
status: { notIn: ["RESOLVED", "CLOSED"] },
},
// Match the user-facing GET shape: hide internal staff notes
include: {
responses: {
where: { isInternal: false },
orderBy: { createdAt: "asc" },
include: {
user: { select: { name: true, role: true } },
},
},
attachments: { orderBy: { uploadedAt: "desc" } },
},
});
if (existing) {
return NextResponse.json(existing, { status: 200 });
}
}

const ticket = await prisma.supportTicket.create({
data: {
title: validatedData.title,
Expand Down
5 changes: 3 additions & 2 deletions app/auth/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { signUp, useSession, sendVerificationEmail } from "@/lib/auth-client";
import { setPendingReferral, clearPendingReferral } from "@/lib/pending-referral";
import { setPendingReferral } from "@/lib/pending-referral";
import { ssoSigninWithGuard } from "@/lib/sso/signin-with-toast";
import { GlobeIcon } from "@/components/auth/auth-icons";
import { SocialLoginButtons } from "@/components/auth/social-login-buttons";
Expand Down Expand Up @@ -88,9 +88,10 @@ function SignUpContent() {
// Persist the referral code at first touch so it survives the OAuth redirect
// and the email-verification gap; it is applied after authentication on the
// onboarding landing. #880
// #891 — landing here WITHOUT ?ref= must not wipe a previously-stashed code;
// an explicit different code simply overwrites the stash.
useEffect(() => {
if (refCode) setPendingReferral(refCode);
else clearPendingReferral();
}, [refCode]);

// Show loading while checking session status (fallback for when middleware doesn't catch)
Expand Down
27 changes: 27 additions & 0 deletions lib/collaborators/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,36 @@
/**
* 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

Check warning on line 75 in lib/collaborators/service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AZ9SeMbbKu499Cc-IgQg&open=AZ9SeMbbKu499Cc-IgQg&pullRequest=989
// 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,
consultantProfileId: string,
role: string,
revenueSharePercentage: number,
invitedById: string,
permissions?: CollaboratorPermissions,
): Promise<Collaborator | null> {
// Validate percentage range
if (revenueSharePercentage <= 0 || revenueSharePercentage > 90) {
Expand All @@ -85,6 +108,8 @@
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({
Expand Down Expand Up @@ -125,6 +150,7 @@
status: "PENDING",
invitedById,
respondedAt: null,
...perms,
},
});
}
Expand All @@ -140,6 +166,7 @@
revenueShareBps: pctToBps(revenueSharePercentage),
status: "PENDING",
invitedById,
...perms,
},
});
},
Expand Down
13 changes: 8 additions & 5 deletions lib/compliance/dpdp.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
Loading
Loading