Skip to content

Commit aee260f

Browse files
committed
fix(ftc-site): stabilize product auth deploy
1 parent b4f4b8d commit aee260f

44 files changed

Lines changed: 18499 additions & 17041 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

APPS/ftc-site/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# Garden Cleaners credentialed E2E accounts
2+
GARDEN_QA_ADMIN_EMAIL=
3+
GARDEN_QA_STAFF_EMAIL=
4+
GARDEN_QA_CUSTOMER_EMAIL=
5+
GARDEN_QA_PASSWORD=
6+
7+
# Optional Una Labs credentialed E2E account
8+
UNA_QA_ADMIN_EMAIL=
9+
UNA_QA_ADMIN_PASSWORD=
110
# FTC Site environment variables
211
# Copy this file to .env.local and fill in the values.
312

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
export const runtime = "edge";
4+
5+
type MapboxFeature = {
6+
place_name?: string;
7+
text?: string;
8+
center?: [number, number];
9+
context?: Array<{ id?: string; text?: string }>;
10+
properties?: Record<string, unknown>;
11+
};
12+
13+
function toText(value: unknown): string {
14+
return typeof value === "string" ? value.trim() : "";
15+
}
16+
17+
function extractContextText(feature: MapboxFeature, prefix: string): string {
18+
const ctx = Array.isArray(feature.context) ? feature.context : [];
19+
const match = ctx.find((item) => String(item?.id || "").startsWith(prefix));
20+
return toText(match?.text);
21+
}
22+
23+
export async function GET(req: NextRequest) {
24+
const { searchParams } = new URL(req.url);
25+
const query = toText(searchParams.get("q"));
26+
27+
if (query.length < 3) {
28+
return NextResponse.json({ ok: true, configured: true, provider: "mapbox", suggestions: [] });
29+
}
30+
31+
const provider = toText(process.env.GARDEN_ADDRESS_AUTOCOMPLETE_PROVIDER || "mapbox").toLowerCase();
32+
const mapboxToken = toText(process.env.GARDEN_MAPBOX_ACCESS_TOKEN || process.env.NEXT_PUBLIC_GARDEN_MAPBOX_TOKEN);
33+
34+
if (provider !== "mapbox" || !mapboxToken) {
35+
return NextResponse.json({
36+
ok: true,
37+
configured: false,
38+
provider: provider || null,
39+
suggestions: [],
40+
reason: "Address autocomplete provider is not configured. Manual address entry is still available."
41+
});
42+
}
43+
44+
try {
45+
const url = new URL(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json`);
46+
url.searchParams.set("access_token", mapboxToken);
47+
url.searchParams.set("autocomplete", "true");
48+
url.searchParams.set("limit", "6");
49+
url.searchParams.set("country", "ca");
50+
url.searchParams.set("types", "address,place,postcode,neighborhood,locality");
51+
52+
const response = await fetch(url.toString(), {
53+
headers: {
54+
"User-Agent": "GardenCleanersAddressAutocomplete/1.0"
55+
}
56+
});
57+
58+
if (!response.ok) {
59+
return NextResponse.json({ ok: false, configured: true, provider: "mapbox", suggestions: [] }, { status: 502 });
60+
}
61+
62+
const body = (await response.json()) as { features?: MapboxFeature[] };
63+
const features = Array.isArray(body.features) ? body.features : [];
64+
65+
const suggestions = features.map((feature, index) => {
66+
const city = extractContextText(feature, "place") || extractContextText(feature, "locality");
67+
const region = extractContextText(feature, "region");
68+
const postalCode = extractContextText(feature, "postcode");
69+
const center = Array.isArray(feature.center) ? feature.center : [];
70+
const longitude = Number(center[0]);
71+
const latitude = Number(center[1]);
72+
73+
return {
74+
id: `${index}-${toText(feature.place_name || feature.text || "address")}`,
75+
label: toText(feature.place_name || feature.text || "Address"),
76+
address: toText(feature.text || feature.place_name || ""),
77+
city,
78+
region,
79+
postalCode,
80+
latitude: Number.isFinite(latitude) ? latitude : null,
81+
longitude: Number.isFinite(longitude) ? longitude : null
82+
};
83+
});
84+
85+
return NextResponse.json({ ok: true, configured: true, provider: "mapbox", suggestions });
86+
} catch {
87+
return NextResponse.json({ ok: true, configured: true, provider: "mapbox", suggestions: [] });
88+
}
89+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@ftc/supabase";
3+
4+
const VALID_VISIBILITY = new Set(["internal", "customer_visible"]);
5+
6+
export async function POST(req: NextRequest) {
7+
const supabase = createServerClient(req.headers);
8+
const { job_id, body, visibility } = await req.json();
9+
10+
const jobId = String(job_id || "").trim();
11+
const noteBody = String(body || "").trim();
12+
const noteVisibility = String(visibility || "internal").trim().toLowerCase();
13+
14+
if (!jobId) {
15+
return NextResponse.json({ ok: false, error: "job_id is required" }, { status: 400 });
16+
}
17+
if (!noteBody) {
18+
return NextResponse.json({ ok: false, error: "Note body is required" }, { status: 400 });
19+
}
20+
if (!VALID_VISIBILITY.has(noteVisibility)) {
21+
return NextResponse.json({ ok: false, error: "Invalid visibility" }, { status: 400 });
22+
}
23+
24+
const { data: actorData, error: actorError } = await supabase.auth.getUser();
25+
if (actorError || !actorData.user) {
26+
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
27+
}
28+
29+
const { data: actorProfile, error: profileError } = await supabase
30+
.from("garden_cleaners_profiles")
31+
.select("role")
32+
.eq("id", actorData.user.id)
33+
.maybeSingle();
34+
35+
if (profileError) {
36+
return NextResponse.json({ ok: false, error: profileError.message }, { status: 500 });
37+
}
38+
if (!actorProfile || actorProfile.role !== "admin") {
39+
return NextResponse.json({ ok: false, error: "Admin access required" }, { status: 403 });
40+
}
41+
42+
const { data: job, error: jobError } = await supabase
43+
.from("garden_cleaners_jobs")
44+
.select("id, customer_email")
45+
.eq("id", jobId)
46+
.maybeSingle();
47+
48+
if (jobError) {
49+
return NextResponse.json({ ok: false, error: jobError.message }, { status: 500 });
50+
}
51+
if (!job) {
52+
return NextResponse.json({ ok: false, error: "Job not found" }, { status: 404 });
53+
}
54+
55+
const action = noteVisibility === "customer_visible" ? "job_customer_comment_added" : "job_internal_note_added";
56+
const { error: auditError } = await supabase.from("garden_cleaners_audit_log").insert({
57+
actor_email: String(actorData.user.email || "unknown").toLowerCase(),
58+
action,
59+
target_email: String(job.customer_email || "").toLowerCase() || null,
60+
details: {
61+
job_id: jobId,
62+
body: noteBody,
63+
visibility: noteVisibility
64+
}
65+
});
66+
67+
if (auditError) {
68+
return NextResponse.json({ ok: false, error: auditError.message }, { status: 500 });
69+
}
70+
71+
return NextResponse.json({ ok: true });
72+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@ftc/supabase";
3+
4+
export async function POST(req: NextRequest) {
5+
const supabase = createServerClient(req.headers);
6+
const { job_id, note } = await req.json();
7+
8+
const jobId = String(job_id || "").trim();
9+
const progressNote = String(note || "").trim();
10+
11+
if (!jobId) {
12+
return NextResponse.json({ ok: false, error: "job_id is required" }, { status: 400 });
13+
}
14+
if (!progressNote) {
15+
return NextResponse.json({ ok: false, error: "note is required" }, { status: 400 });
16+
}
17+
18+
const { data: actorData, error: actorError } = await supabase.auth.getUser();
19+
if (actorError || !actorData.user) {
20+
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
21+
}
22+
23+
const { data: actorProfile, error: profileError } = await supabase
24+
.from("garden_cleaners_profiles")
25+
.select("id, role, is_active")
26+
.eq("auth_user_id", actorData.user.id)
27+
.maybeSingle();
28+
29+
if (profileError) {
30+
return NextResponse.json({ ok: false, error: profileError.message }, { status: 500 });
31+
}
32+
if (!actorProfile || actorProfile.role !== "staff" || actorProfile.is_active !== true) {
33+
return NextResponse.json({ ok: false, error: "Staff access required" }, { status: 403 });
34+
}
35+
36+
const { data: job, error: jobError } = await supabase
37+
.from("garden_cleaners_jobs")
38+
.select("id, customer_email, staff_profile_id")
39+
.eq("id", jobId)
40+
.maybeSingle();
41+
42+
if (jobError) {
43+
return NextResponse.json({ ok: false, error: jobError.message }, { status: 500 });
44+
}
45+
if (!job) {
46+
return NextResponse.json({ ok: false, error: "Job not found" }, { status: 404 });
47+
}
48+
if (String(job.staff_profile_id || "") !== String(actorProfile.id || "")) {
49+
return NextResponse.json({ ok: false, error: "Staff can only add progress notes to assigned jobs" }, { status: 403 });
50+
}
51+
52+
const { error: insertError } = await supabase.from("garden_cleaners_audit_log").insert({
53+
actor_email: String(actorData.user.email || "unknown").toLowerCase(),
54+
action: "job_progress_note_added",
55+
target_email: String(job.customer_email || "").toLowerCase() || null,
56+
details: {
57+
job_id: jobId,
58+
note: progressNote
59+
}
60+
});
61+
62+
if (insertError) {
63+
return NextResponse.json({ ok: false, error: insertError.message }, { status: 500 });
64+
}
65+
66+
return NextResponse.json({ ok: true });
67+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@ftc/supabase";
3+
import { saveGardenPushSubscription } from "../../../lib/gardenPortalNotifications";
4+
5+
export async function POST(req: NextRequest) {
6+
const supabase = createServerClient(req.headers);
7+
const { data: authData, error: authError } = await supabase.auth.getUser();
8+
if (authError || !authData.user?.email) {
9+
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
10+
}
11+
12+
const payload = await req.json().catch(() => null);
13+
const subscription = payload?.subscription;
14+
if (!subscription) {
15+
return NextResponse.json({ ok: false, error: "subscription is required" }, { status: 400 });
16+
}
17+
18+
try {
19+
const result = await saveGardenPushSubscription(supabase, {
20+
userId: String(authData.user.id || ""),
21+
userEmail: String(authData.user.email),
22+
subscription
23+
});
24+
return NextResponse.json({ ok: true, pushReady: result.pushReady });
25+
} catch (error: unknown) {
26+
const message = error instanceof Error ? error.message : "Unable to save subscription";
27+
return NextResponse.json({ ok: false, error: message }, { status: 500 });
28+
}
29+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@ftc/supabase";
3+
import { listGardenNotifications, markGardenNotificationRead } from "../../../lib/gardenPortalNotifications";
4+
5+
export async function GET(req: NextRequest) {
6+
const supabase = createServerClient(req.headers);
7+
const { data: authData, error: authError } = await supabase.auth.getUser();
8+
if (authError || !authData.user?.email) {
9+
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
10+
}
11+
12+
const { searchParams } = new URL(req.url);
13+
const limit = Number(searchParams.get("limit") || 30);
14+
15+
try {
16+
const notifications = await listGardenNotifications(
17+
supabase,
18+
String(authData.user.email),
19+
String(authData.user.id || ""),
20+
limit
21+
);
22+
return NextResponse.json({ ok: true, notifications });
23+
} catch (error: unknown) {
24+
const message = error instanceof Error ? error.message : "Unable to load notifications";
25+
return NextResponse.json({ ok: false, error: message }, { status: 500 });
26+
}
27+
}
28+
29+
export async function PATCH(req: NextRequest) {
30+
const supabase = createServerClient(req.headers);
31+
const { data: authData, error: authError } = await supabase.auth.getUser();
32+
if (authError || !authData.user?.email) {
33+
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
34+
}
35+
36+
const payload = await req.json().catch(() => null);
37+
const notificationId = String(payload?.notification_id || "").trim();
38+
if (!notificationId) {
39+
return NextResponse.json({ ok: false, error: "notification_id is required" }, { status: 400 });
40+
}
41+
42+
try {
43+
await markGardenNotificationRead(
44+
supabase,
45+
notificationId,
46+
String(authData.user.email),
47+
String(authData.user.id || "")
48+
);
49+
return NextResponse.json({ ok: true });
50+
} catch (error: unknown) {
51+
const message = error instanceof Error ? error.message : "Unable to mark notification";
52+
return NextResponse.json({ ok: false, error: message }, { status: 500 });
53+
}
54+
}

APPS/ftc-site/app/api/garden-cleaners-quote/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,8 @@ export async function POST(req: NextRequest) {
248248
const supabase = createServerClient();
249249
let { error } = await supabase.from("garden_cleaners_quotes").insert([quoteRecord]);
250250
if (error && hasMissingAddOnsColumn(error)) {
251-
const { add_ons, ...quoteRecordWithoutAddOns } = quoteRecord;
251+
const { add_ons: _addOns, ...quoteRecordWithoutAddOns } = quoteRecord;
252+
void _addOns;
252253
({ error } = await supabase.from("garden_cleaners_quotes").insert([quoteRecordWithoutAddOns]));
253254
}
254255
if (error) {

0 commit comments

Comments
 (0)