Skip to content

Commit f1a0a3f

Browse files
feat: implement customer task self-completion in portal
- Add customerOwned boolean to tasks table schema (migration 001) - Portal GET /api/portal/:token — returns customer + tasks for a portal token - Portal POST /api/portal/:token/tasks/:taskId/complete — validates token, verifies customerOwned===true, marks task complete, notifies CSM (idempotent) - Frontend: /portal/:token — interactive checkboxes for customer-owned tasks with optimistic updates, revert on error, success toast, and progress bar - CSM dashboard: /dashboard — customer list + unread notification feed - Task editor: toggle customerOwned per task, add new tasks - Notifications: in-app notification to CSM when customer completes a task - DB schema: customers, portal_tokens, tasks, notifications, csm_users Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 74885c5 commit f1a0a3f

24 files changed

Lines changed: 3221 additions & 0 deletions

File tree

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# dependencies
2+
/node_modules
3+
4+
# next.js
5+
/.next/
6+
/out/
7+
8+
# env files
9+
.env
10+
.env.local
11+
.env.development.local
12+
.env.test.local
13+
.env.production.local
14+
15+
# typescript
16+
tsconfig.tsbuildinfo
17+
18+
# misc
19+
.DS_Store

app/api/notifications/route.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@/lib/supabase";
3+
4+
export async function GET(request: NextRequest) {
5+
const supabase = createServerClient();
6+
const { searchParams } = new URL(request.url);
7+
const csmId = searchParams.get("csm_id");
8+
9+
const query = supabase
10+
.from("notifications")
11+
.select("*")
12+
.order("created_at", { ascending: false })
13+
.limit(50);
14+
15+
if (csmId) query.eq("recipient_csm_id", csmId);
16+
17+
const { data, error } = await query;
18+
if (error) return NextResponse.json({ error: "Failed to fetch notifications" }, { status: 500 });
19+
20+
return NextResponse.json(data ?? []);
21+
}
22+
23+
// Mark notification(s) as read
24+
export async function PATCH(request: NextRequest) {
25+
const supabase = createServerClient();
26+
const body = await request.json() as { ids?: string[] };
27+
28+
if (!body.ids?.length) {
29+
return NextResponse.json({ error: "ids required" }, { status: 400 });
30+
}
31+
32+
const { error } = await supabase
33+
.from("notifications")
34+
.update({ read: true })
35+
.in("id", body.ids);
36+
37+
if (error) return NextResponse.json({ error: "Failed to update notifications" }, { status: 500 });
38+
39+
return NextResponse.json({ success: true });
40+
}

app/api/portal/[token]/route.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@/lib/supabase";
3+
4+
interface RouteParams {
5+
params: Promise<{ token: string }>;
6+
}
7+
8+
export async function GET(_request: NextRequest, { params }: RouteParams) {
9+
const { token } = await params;
10+
const supabase = createServerClient();
11+
12+
const { data: portalToken, error: tokenError } = await supabase
13+
.from("portal_tokens")
14+
.select("*, customers(*)")
15+
.eq("token", token)
16+
.single();
17+
18+
if (tokenError || !portalToken) {
19+
return NextResponse.json({ error: "Invalid portal token" }, { status: 401 });
20+
}
21+
22+
if (portalToken.expires_at && new Date(portalToken.expires_at) < new Date()) {
23+
return NextResponse.json({ error: "Portal token has expired" }, { status: 401 });
24+
}
25+
26+
const customer = portalToken.customers as { id: string; name: string; company: string | null };
27+
28+
const { data: tasks, error: tasksError } = await supabase
29+
.from("tasks")
30+
.select("*")
31+
.eq("customer_id", customer.id)
32+
.order("created_at", { ascending: true });
33+
34+
if (tasksError) {
35+
return NextResponse.json({ error: "Failed to fetch tasks" }, { status: 500 });
36+
}
37+
38+
return NextResponse.json({ customer, tasks: tasks ?? [], token });
39+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@/lib/supabase";
3+
4+
interface RouteParams {
5+
params: Promise<{ token: string; taskId: string }>;
6+
}
7+
8+
export async function POST(request: NextRequest, { params }: RouteParams) {
9+
const { token, taskId } = await params;
10+
const supabase = createServerClient();
11+
12+
// 1. Validate portal token
13+
const { data: portalToken, error: tokenError } = await supabase
14+
.from("portal_tokens")
15+
.select("*, customers(*)")
16+
.eq("token", token)
17+
.single();
18+
19+
if (tokenError || !portalToken) {
20+
return NextResponse.json({ error: "Invalid portal token" }, { status: 401 });
21+
}
22+
23+
// Check expiry
24+
if (portalToken.expires_at && new Date(portalToken.expires_at) < new Date()) {
25+
return NextResponse.json({ error: "Portal token has expired" }, { status: 401 });
26+
}
27+
28+
const customer = portalToken.customers as { id: string; name: string; csm_id: string | null };
29+
30+
// 2. Fetch the task and verify ownership
31+
const { data: task, error: taskError } = await supabase
32+
.from("tasks")
33+
.select("*")
34+
.eq("id", taskId)
35+
.eq("customer_id", customer.id)
36+
.single();
37+
38+
if (taskError || !task) {
39+
return NextResponse.json({ error: "Task not found" }, { status: 404 });
40+
}
41+
42+
// 3. Verify task is customer-owned
43+
if (!task.customer_owned) {
44+
return NextResponse.json(
45+
{ error: "This task cannot be completed by the customer" },
46+
{ status: 403 }
47+
);
48+
}
49+
50+
// 4. Idempotent: already complete → return 200 without notifying
51+
if (task.completed) {
52+
return NextResponse.json({ success: true, already_complete: true });
53+
}
54+
55+
// 5. Mark task complete
56+
const now = new Date().toISOString();
57+
const { error: updateError } = await supabase
58+
.from("tasks")
59+
.update({
60+
completed: true,
61+
completed_by: "customer",
62+
completed_at: now,
63+
updated_at: now,
64+
})
65+
.eq("id", taskId);
66+
67+
if (updateError) {
68+
console.error("Failed to update task:", updateError);
69+
return NextResponse.json({ error: "Failed to complete task" }, { status: 500 });
70+
}
71+
72+
// 6. Trigger CSM notification
73+
if (customer.csm_id) {
74+
const portalLink = `${process.env.NEXT_PUBLIC_APP_URL}/portal/${token}`;
75+
const message = `${customer.name} completed '${task.title}'`;
76+
77+
const { error: notifError } = await supabase.from("notifications").insert({
78+
recipient_csm_id: customer.csm_id,
79+
message,
80+
link: portalLink,
81+
});
82+
83+
if (notifError) {
84+
// Non-fatal: log but don't fail the request
85+
console.error("Failed to create CSM notification:", notifError);
86+
}
87+
}
88+
89+
return NextResponse.json({ success: true });
90+
}

app/api/tasks/[taskId]/route.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@/lib/supabase";
3+
4+
interface RouteParams {
5+
params: Promise<{ taskId: string }>;
6+
}
7+
8+
export async function PATCH(request: NextRequest, { params }: RouteParams) {
9+
const { taskId } = await params;
10+
const supabase = createServerClient();
11+
12+
const body = await request.json() as Record<string, unknown>;
13+
14+
// Only allow updating specific fields from the dashboard
15+
const allowed = ["title", "description", "customer_owned"] as const;
16+
const updates: Record<string, unknown> = { updated_at: new Date().toISOString() };
17+
for (const key of allowed) {
18+
if (key in body) updates[key] = body[key];
19+
}
20+
21+
const { data, error } = await supabase
22+
.from("tasks")
23+
.update(updates)
24+
.eq("id", taskId)
25+
.select()
26+
.single();
27+
28+
if (error) {
29+
return NextResponse.json({ error: "Failed to update task" }, { status: 500 });
30+
}
31+
32+
return NextResponse.json(data);
33+
}

app/api/tasks/route.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@/lib/supabase";
3+
4+
export async function POST(request: NextRequest) {
5+
const supabase = createServerClient();
6+
const body = await request.json();
7+
8+
const { customer_id, title, description, customer_owned } = body as {
9+
customer_id?: string;
10+
title?: string;
11+
description?: string;
12+
customer_owned?: boolean;
13+
};
14+
15+
if (!customer_id || !title) {
16+
return NextResponse.json({ error: "customer_id and title are required" }, { status: 400 });
17+
}
18+
19+
const { data, error } = await supabase
20+
.from("tasks")
21+
.insert({ customer_id, title, description: description ?? null, customer_owned: !!customer_owned })
22+
.select()
23+
.single();
24+
25+
if (error) {
26+
return NextResponse.json({ error: "Failed to create task" }, { status: 500 });
27+
}
28+
29+
return NextResponse.json(data, { status: 201 });
30+
}

0 commit comments

Comments
 (0)