Skip to content
Merged
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
502 changes: 502 additions & 0 deletions docs/ADVANCED-FILTERS-BRIEF.md

Large diffs are not rendered by default.

74 changes: 56 additions & 18 deletions src/app/(main)/(dashboard)/classes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { getCurrentUserTenant } from "@/lib/supabase/queries";
import { getFeatureAccess } from "@/industries/_loader";
import { FEATURES } from "@/industries/_registry";
import { createClient, createServiceClient } from "@/lib/supabase/server";
import { leadQueryScope, canEnrollStudents } from "@/lib/api/permissions";
import { leadQueryScope } from "@/lib/api/permissions";
import { canEnrollStudents, canMarkClassAttendance, canViewFullRoster } from "@/lib/api/class-attendance";
import { branchMemberIds } from "@/lib/leads/branch-membership";
import { visibleLeadsBase } from "@/lib/leads/visibility-query";
import { POSITION_ROUTE_MAP } from "@/industries/education-consultancy/features/new-leads-triage/position-routing";
Expand Down Expand Up @@ -57,26 +58,24 @@ export default async function ClassesRoute() {
: null;
const scope = leadQueryScope(tenantData.permissions, tenantData.userId, tenantData.branchId ?? null, poolSlug);

// Attendance markers need the full class roster to mark attendance — own-scope
// lead filtering (built for the leads list) would otherwise hide classmates
// they aren't personally assigned to. Compute this before the roster query so
// it can bypass the own-scope restriction below.
const canMarkAttendance =
tenantData.role === "owner" ||
tenantData.role === "admin" ||
!!(
await supabase
.from("class_attendance_markers")
.select("user_id")
.eq("tenant_id", tenantData.tenant.id)
.eq("user_id", tenantData.userId)
.maybeSingle()
).data;
const authSubject = { role: tenantData.role, userId: tenantData.userId, tenantId: tenantData.tenant.id };

// Roster-view bypass: class_managers.view_roster grants full-roster visibility
// independent of attendance-marking capability — own-scope lead filtering (built
// for the leads list) would otherwise hide classmates the viewer isn't personally
// assigned to. Compute this before the roster query so it can bypass the
// own-scope restriction below. canMarkAttendance below is the separate
// capability that gates the "Take attendance" button.
const [canViewRoster, canMarkAttendance, canEnroll] = await Promise.all([
canViewFullRoster(authSubject),
canMarkClassAttendance(authSubject),
canEnrollStudents(authSubject),
]);

let leadIds: string[] | null = null;
let teamMemberIds: string[] | null = null;

if (scope.restrictToSelf && scope.userId && !canMarkAttendance) {
if (scope.restrictToSelf && scope.userId && !canViewRoster) {
// Visibility-scoped (uncapped; migration 179) — includes collaborator-visible leads,
// not just direct assignments.
const { data, error } = await visibleLeadsBase({ user: userClient, service: supabase }, tenantData.tenant.id, scope).is("deleted_at", null);
Expand Down Expand Up @@ -121,15 +120,54 @@ export default async function ClassesRoute() {
end_date: string | null;
}>;

// Fees totals (aggregate amount + per-class collection %) are owner-only —
// computed here, not in the client, so a non-owner is never handed a
// precomputed total to read off props/devtools. Per-student fee_amount still
// ships in `enrollments` regardless of role — that's the explicit, separate
// "individual fee stays visible to roster viewers" requirement — so this
// narrows the specific gap (a ready-made aggregate on a platter), it does not
// make the aggregate unreconstructable by someone who can already see every
// student's fee (summing what they're allowed to see was never in scope to
// prevent).
const canSeeFeesTotals = tenantData.role === "owner";
let feesCollected: number | null = null;
let classFeePct: Record<string, number | null> | null = null;
if (canSeeFeesTotals) {
let total = 0;
const byClass: Record<string, { paid: number; payable: number }> = {};
for (const e of enrollments) {
const feePaid = e.fee_paid as boolean;
const feeAmount = e.fee_amount as number | null;
const status = e.status as string;
const classId = e.class_id as string;
if (feePaid && feeAmount != null) total += feeAmount;
if (status !== "inactive") {
const entry = byClass[classId] ?? { paid: 0, payable: 0 };
entry.payable += 1;
if (feePaid) entry.paid += 1;
byClass[classId] = entry;
}
}
feesCollected = total;
classFeePct = {};
for (const cls of classes) {
const entry = byClass[cls.id];
classFeePct[cls.id] = entry && entry.payable > 0 ? Math.round((entry.paid / entry.payable) * 100) : null;
Comment on lines +138 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Calculate fee percentage per student.

A lead can have both demo and actual enrollment rows. This loop counts both rows in paid and payable, but the workspace displays student counts and treats actual enrollment as primary. The fee percentage can therefore be incorrect.

Deduplicate by (class_id, lead_id) and prefer the actual enrollment before calculating paid and payable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(main)/(dashboard)/classes/page.tsx around lines 138 - 155, Update
the enrollment aggregation loop before calculating class fee percentages to
deduplicate rows by (class_id, lead_id), preferring the actual enrollment over
any demo enrollment for the same student and class. Then compute total fees,
paid counts, and payable counts from only the selected enrollment records while
preserving the existing inactive-status and classFeePct behavior.

}
}

return (
<div className="flex flex-col h-[calc(100vh-90px)]">
<ClassesWorkspace
classes={classes}
enrollments={enrollments}
canManage={tenantData.permissions.canManageClasses}
canEnroll={canEnrollStudents(tenantData.permissions, tenantData.positionSlug)}
canEnroll={canEnroll}
canMarkAttendance={canMarkAttendance}
tenantId={tenantData.tenant.id}
canSeeFeesTotals={canSeeFeesTotals}
feesCollected={feesCollected}
classFeePct={classFeePct}
/>
</div>
);
Expand Down
5 changes: 3 additions & 2 deletions src/app/(main)/(dashboard)/leads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
} from "@/lib/supabase/queries";
import { createServiceClient } from "@/lib/supabase/server";
import { LeadDetailV2 } from "@/components/dashboard/lead/lead-detail-v2";
import { canSeeNav, canAccessList, leadQueryScope, canEnrollStudents } from "@/lib/api/permissions";
import { canSeeNav, canAccessList, leadQueryScope } from "@/lib/api/permissions";
import { canEnrollStudents } from "@/lib/api/class-attendance";
import { canBypassProspectQualification } from "@/lib/leads/prospect-qualification";
import { canCreateOrReorderApplications } from "@/lib/api/applications";
import { isOffFunnelLeadList } from "@/lib/leads/list-funnel";
Expand Down Expand Up @@ -311,7 +312,7 @@ export default async function LeadDetailPage({
stageAssigneeMap={stageAssigneeMap}
canManageApplications={tenantData.permissions.canManageApplications}
canManageApplicationPanel={canCreateOrReorderApplications(tenantData, lead)}
canEnroll={canEnrollStudents(tenantData.permissions, tenantData.positionSlug)}
canEnroll={await canEnrollStudents({ role: tenantData.role, userId: tenantData.userId, tenantId: tenantData.tenant.id })}
leadLists={accessibleLists}
activeLeadLists={activeLeadLists}
classesActive={classesActive}
Expand Down
7 changes: 4 additions & 3 deletions src/app/(main)/api/v1/class-enrollments/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { createServiceClient } from "@/lib/supabase/server";
import { getFeatureAccess } from "@/industries/_loader";
import { FEATURES } from "@/industries/_registry";
import { createAuditLog, emitEvent } from "@/lib/api/audit";
import { shouldRestrictToSelf, canEnrollStudents } from "@/lib/api/permissions";
import { shouldRestrictToSelf } from "@/lib/api/permissions";
import { canEnrollStudents } from "@/lib/api/class-attendance";
import { getLeadMembership } from "@/lib/leads/branch-membership";

interface Props {
Expand Down Expand Up @@ -74,7 +75,7 @@ export async function PATCH(request: NextRequest, { params }: Props) {
const auth = await authenticateRequest();
if (!auth) return apiUnauthorized();
if (!getFeatureAccess(auth.industryId, FEATURES.CLASSES)) return apiForbidden();
if (!canEnrollStudents(auth.permissions, auth.positionSlug)) return apiForbidden();
if (!(await canEnrollStudents(auth))) return apiForbidden();

let body: Record<string, unknown>;
try {
Expand Down Expand Up @@ -182,7 +183,7 @@ export async function DELETE(_request: NextRequest, { params }: Props) {
const auth = await authenticateRequest();
if (!auth) return apiUnauthorized();
if (!getFeatureAccess(auth.industryId, FEATURES.CLASSES)) return apiForbidden();
if (!canEnrollStudents(auth.permissions, auth.positionSlug)) return apiForbidden();
if (!(await canEnrollStudents(auth))) return apiForbidden();

const supabase = await createServiceClient();
const db = await scopedClient(auth);
Expand Down
5 changes: 3 additions & 2 deletions src/app/(main)/api/v1/class-enrollments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { createServiceClient } from "@/lib/supabase/server";
import { getFeatureAccess } from "@/industries/_loader";
import { FEATURES } from "@/industries/_registry";
import { createAuditLog, emitEvent } from "@/lib/api/audit";
import { shouldRestrictToSelf, canEnrollStudents } from "@/lib/api/permissions";
import { shouldRestrictToSelf } from "@/lib/api/permissions";
import { canEnrollStudents } from "@/lib/api/class-attendance";
import { getLeadMembership } from "@/lib/leads/branch-membership";

export async function GET(request: NextRequest) {
Expand Down Expand Up @@ -82,7 +83,7 @@ export async function POST(request: NextRequest) {
const auth = await authenticateRequest();
if (!auth) return apiUnauthorized();
if (!getFeatureAccess(auth.industryId, FEATURES.CLASSES)) return apiForbidden();
if (!canEnrollStudents(auth.permissions, auth.positionSlug)) return apiForbidden();
if (!(await canEnrollStudents(auth))) return apiForbidden();

let body: Record<string, unknown>;
try {
Expand Down
169 changes: 169 additions & 0 deletions src/app/(main)/api/v1/class-managers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { NextRequest } from "next/server";
import { authenticateRequest } from "@/lib/api/auth";
import {
apiSuccess,
apiUnauthorized,
apiForbidden,
apiError,
apiValidationError,
} from "@/lib/api/response";
import { validate, required } from "@/lib/api/validation";
import { createRequestLogger } from "@/lib/logger";
import { scopedClient } from "@/lib/supabase/scoped";
import { getFeatureAccess } from "@/industries/_loader";
import { FEATURES } from "@/industries/_registry";
import { createAuditLog, emitEvent } from "@/lib/api/audit";

interface ClassManagerRow {
tenant_id: string;
user_id: string;
enroll_students: boolean;
mark_attendance: boolean;
view_roster: boolean;
granted_by: string | null;
created_at: string;
updated_at: string;
}

// GET /api/v1/class-managers — list all class_managers grants for the tenant,
// enriched with user email/display name for the settings table. Owner/admin only.
export async function GET() {
const auth = await authenticateRequest();
if (!auth) return apiUnauthorized();
if (!getFeatureAccess(auth.industryId, FEATURES.CLASSES)) return apiForbidden();
if (auth.role !== "owner" && auth.role !== "admin") return apiForbidden();

const db = await scopedClient(auth);

const { data: grants, error } = await db
.from("class_managers")
.select("*")
.order("created_at", { ascending: true });

if (error) return apiError("DB_ERROR", "Failed to fetch class managers", 500);

const rows = (grants ?? []) as unknown as ClassManagerRow[];

// Enrich with user email/name via auth.admin — same pattern as /api/v1/team.
const { data: authData } = await db.raw().auth.admin.listUsers({ perPage: 1000 });
const userMap = new Map<string, string>();
const nameMap = new Map<string, string | null>();
for (const u of authData?.users || []) {
userMap.set(u.id, u.email || "");
const meta = u.user_metadata as Record<string, unknown> | undefined;
nameMap.set(u.id, (meta?.name ?? meta?.full_name ?? null) as string | null);
}

const enriched = rows.map((r) => ({
userId: r.user_id,
email: userMap.get(r.user_id) || "Unknown",
name: nameMap.get(r.user_id) ?? null,
enrollStudents: r.enroll_students,
markAttendance: r.mark_attendance,
viewRoster: r.view_roster,
grantedBy: r.granted_by,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));

return apiSuccess(enriched);
}

// PATCH /api/v1/class-managers — upsert a single user's grant.
// Body: { userId, enrollStudents, markAttendance, viewRoster }. Owner/admin only.
export async function PATCH(request: NextRequest) {
const requestId = crypto.randomUUID();
const log = createRequestLogger({ requestId, method: "PATCH", path: "/api/v1/class-managers" });

const auth = await authenticateRequest();
if (!auth) return apiUnauthorized();
if (!getFeatureAccess(auth.industryId, FEATURES.CLASSES)) return apiForbidden();
if (auth.role !== "owner" && auth.role !== "admin") return apiForbidden();

let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return apiError("INVALID_JSON", "Request body must be valid JSON", 400);
}

const { valid, errors } = validate(body, {
userId: [required("userId")],
});
if (!valid) return apiValidationError(errors);

const userId = String(body.userId);
const enrollStudents = Boolean(body.enrollStudents);
const markAttendance = Boolean(body.markAttendance);
const viewRoster = Boolean(body.viewRoster);

const db = await scopedClient(auth);

// Confirm the target user belongs to this tenant before granting.
const { data: member } = await db
.from("tenant_users")
.select("user_id")
.eq("user_id", userId)
.maybeSingle();
if (!member) return apiError("NOT_FOUND", "User is not a member of this tenant", 404);

const { data: existing } = await db
.from("class_managers")
.select("enroll_students, mark_attendance, view_roster")
.eq("user_id", userId)
.maybeSingle() as { data: Pick<ClassManagerRow, "enroll_students" | "mark_attendance" | "view_roster"> | null };

const { data: upserted, error } = await db
.from("class_managers")
.upsert(
{
user_id: userId,
enroll_students: enrollStudents,
mark_attendance: markAttendance,
view_roster: viewRoster,
granted_by: auth.userId,
},
{ onConflict: "tenant_id,user_id" }
)
.select("*")
.single();
Comment on lines +116 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Prevent stale capability snapshots from overwriting grants.

The PATCH handler treats all three capability values as an authoritative snapshot. The UI can send false defaults after a failed grants request. Two field toggles can also complete out of order because savingKey locks only one control. A late request can re-grant or revoke another capability.

  • src/app/(main)/api/v1/class-managers/route.ts#L116-L129: accept a field-level mutation or enforce a revision check before replacing all capability values.
  • src/components/dashboard/settings/class-managers.tsx#L65-L70: fail the load when either response is not OK. Do not replace failed grant data with an empty list.
  • src/components/dashboard/settings/class-managers.tsx#L101-L138: serialize mutations per user, or lock all three controls until the request completes and refresh from the server response.
📍 Affects 2 files
  • src/app/(main)/api/v1/class-managers/route.ts#L116-L129 (this comment)
  • src/components/dashboard/settings/class-managers.tsx#L65-L70
  • src/components/dashboard/settings/class-managers.tsx#L101-L138
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(main)/api/v1/class-managers/route.ts around lines 116 - 129,
Prevent stale capability snapshots from overwriting grants: in
src/app/(main)/api/v1/class-managers/route.ts lines 116-129, update the PATCH
flow to accept field-level mutations or enforce a revision check before
replacing all capabilities; in
src/components/dashboard/settings/class-managers.tsx lines 65-70, fail loading
when either response is not OK instead of substituting an empty grant list; in
lines 101-138, serialize per-user mutations or disable all three controls during
saves and refresh state from the server response.


if (error) {
log.error({ error }, "Failed to upsert class manager grant");
return apiError("DB_ERROR", "Failed to update class manager grant", 500);
}

await Promise.all([
createAuditLog({
tenantId: auth.tenantId,
userId: auth.userId,
action: "class_manager.updated",
entityType: "class_manager",
entityId: userId,
changes: {
grant: {
old: existing
? {
enrollStudents: existing.enroll_students,
markAttendance: existing.mark_attendance,
viewRoster: existing.view_roster,
}
: null,
new: { enrollStudents, markAttendance, viewRoster },
},
},
requestId,
}),
emitEvent({
tenantId: auth.tenantId,
type: "class_manager.updated",
entityType: "class_manager",
entityId: userId,
requestId,
payload: { enrollStudents, markAttendance, viewRoster },
}),
]);

log.info({ userId }, "Class manager grant updated");
return apiSuccess(upserted);
}
5 changes: 3 additions & 2 deletions src/app/(main)/api/v1/leads/[id]/classes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { NextRequest } from "next/server";
import { createServiceClient } from "@/lib/supabase/server";
import { authenticateRequest, requireLeadBranchAccess } from "@/lib/api/auth";
import { getLeadMembership } from "@/lib/leads/branch-membership";
import { shouldRestrictToSelf, canEnrollStudents } from "@/lib/api/permissions";
import { shouldRestrictToSelf } from "@/lib/api/permissions";
import { canEnrollStudents } from "@/lib/api/class-attendance";
import {
apiSuccess,
apiUnauthorized,
Expand Down Expand Up @@ -77,7 +78,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
const auth = await authenticateRequest();
if (!auth) return apiUnauthorized();
if (!getFeatureAccess(auth.industryId, FEATURES.CLASSES)) return apiForbidden();
if (!canEnrollStudents(auth.permissions, auth.positionSlug)) return apiForbidden();
if (!(await canEnrollStudents(auth))) return apiForbidden();

const supabase = await createServiceClient();

Expand Down
Loading
Loading