Skip to content

Commit 9b6acb4

Browse files
committed
feat: Implement competitor update API with history reset on URL change, add user data export and account deletion functionality, and introduce encryption for sensitive user settings.
1 parent 55b7d51 commit 9b6acb4

8 files changed

Lines changed: 730 additions & 4 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@/lib/supabase";
3+
import { getUserId } from "@/lib/auth";
4+
5+
/**
6+
* Competitor Update API
7+
*
8+
* PATCH /api/competitors/[id] - Update competitor name/URL
9+
*
10+
* If URL changes, all historical data (snapshots, alerts) is reset
11+
* to maintain data integrity and prevent misuse.
12+
*/
13+
14+
interface RouteParams {
15+
params: Promise<{ id: string }>;
16+
}
17+
18+
export async function PATCH(request: NextRequest, { params }: RouteParams) {
19+
try {
20+
const userId = await getUserId();
21+
22+
if (!userId) {
23+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
24+
}
25+
26+
const { id } = await params;
27+
28+
if (!id) {
29+
return NextResponse.json(
30+
{ error: "Competitor ID is required" },
31+
{ status: 400 }
32+
);
33+
}
34+
35+
const body = await request.json();
36+
const { name, url } = body;
37+
38+
// At least one field must be provided
39+
if (!name && !url) {
40+
return NextResponse.json(
41+
{ error: "Name or URL is required" },
42+
{ status: 400 }
43+
);
44+
}
45+
46+
// Validate URL format if provided
47+
if (url) {
48+
try {
49+
new URL(url);
50+
} catch {
51+
return NextResponse.json(
52+
{ error: "Invalid URL format" },
53+
{ status: 400 }
54+
);
55+
}
56+
}
57+
58+
const supabase = createServerClient();
59+
60+
// Fetch current competitor and verify ownership
61+
const { data: competitor, error: fetchError } = await supabase
62+
.from("competitors")
63+
.select("*")
64+
.eq("id", id)
65+
.single();
66+
67+
if (fetchError || !competitor) {
68+
return NextResponse.json({ error: "Competitor not found" }, { status: 404 });
69+
}
70+
71+
if (competitor.user_id !== userId) {
72+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
73+
}
74+
75+
// Check if URL is changing
76+
const urlIsChanging = url && url !== competitor.url;
77+
let historyReset = false;
78+
79+
if (urlIsChanging) {
80+
// Delete all snapshots for this competitor
81+
const { error: snapshotDeleteError } = await supabase
82+
.from("snapshots")
83+
.delete()
84+
.eq("competitor_id", id);
85+
86+
if (snapshotDeleteError) {
87+
console.error("Error deleting snapshots:", snapshotDeleteError);
88+
// Non-fatal: continue with update
89+
}
90+
91+
// Delete all alerts for this competitor
92+
const { error: alertDeleteError } = await supabase
93+
.from("alerts")
94+
.delete()
95+
.eq("competitor_id", id);
96+
97+
if (alertDeleteError) {
98+
console.error("Error deleting alerts:", alertDeleteError);
99+
// Non-fatal: continue with update
100+
}
101+
102+
historyReset = true;
103+
console.log(`[Competitors] URL changed for ${competitor.name}, history reset`);
104+
}
105+
106+
// Prepare update payload
107+
const updatePayload: Record<string, unknown> = {};
108+
109+
if (name) {
110+
updatePayload.name = name;
111+
}
112+
113+
if (url) {
114+
updatePayload.url = url;
115+
}
116+
117+
// If URL changed, reset tracking fields
118+
if (urlIsChanging) {
119+
updatePayload.last_checked_at = null;
120+
updatePayload.failure_count = 0;
121+
updatePayload.status = "active";
122+
}
123+
124+
// Update competitor
125+
const { data: updatedCompetitor, error: updateError } = await supabase
126+
.from("competitors")
127+
.update(updatePayload)
128+
.eq("id", id)
129+
.select()
130+
.single();
131+
132+
if (updateError) {
133+
console.error("Error updating competitor:", updateError);
134+
return NextResponse.json({ error: "Failed to update competitor" }, { status: 500 });
135+
}
136+
137+
return NextResponse.json({
138+
competitor: updatedCompetitor,
139+
historyReset,
140+
message: historyReset
141+
? "Competitor updated. Historical data has been reset due to URL change."
142+
: "Competitor updated successfully."
143+
});
144+
} catch (error) {
145+
console.error("Unexpected error:", error);
146+
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
147+
}
148+
}

src/app/api/settings/route.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createServerClient } from "@/lib/supabase";
33
import { getUserId } from "@/lib/auth";
44
import type { UserSettings } from "@/lib/types";
55
import { DEFAULT_USER_SETTINGS } from "@/lib/types";
6+
import { encryptWebhookUrl, decryptWebhookUrl } from "@/lib/encryption";
67

78
/**
89
* Settings API
@@ -40,6 +41,13 @@ export async function GET() {
4041
...(user?.settings || {}),
4142
};
4243

44+
// Decrypt webhook URL for client display (masked)
45+
if (settings.slack_webhook_url) {
46+
const decrypted = decryptWebhookUrl(settings.slack_webhook_url);
47+
// Return masked version for security
48+
settings.slack_webhook_url = decrypted ? "••••••••" + decrypted.slice(-20) : null;
49+
}
50+
4351
return NextResponse.json({ settings });
4452
} catch (error) {
4553
console.error("Unexpected error:", error);
@@ -72,7 +80,15 @@ export async function PATCH(request: NextRequest) {
7280
if (body.slack_webhook_url === null || body.slack_webhook_url === "") {
7381
updates.slack_webhook_url = null;
7482
} else if (typeof body.slack_webhook_url === "string" && body.slack_webhook_url.startsWith("https://hooks.slack.com/")) {
75-
updates.slack_webhook_url = body.slack_webhook_url;
83+
// Encrypt the webhook URL before storing
84+
const encrypted = encryptWebhookUrl(body.slack_webhook_url);
85+
if (!encrypted) {
86+
return NextResponse.json(
87+
{ error: "Failed to secure webhook URL. Please try again." },
88+
{ status: 500 }
89+
);
90+
}
91+
updates.slack_webhook_url = encrypted;
7692
} else {
7793
return NextResponse.json(
7894
{ error: "Invalid Slack webhook URL. Must start with https://hooks.slack.com/" },

src/app/api/user/route.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { NextResponse } from "next/server";
2+
import { createServerClient } from "@/lib/supabase";
3+
import { getUserId, getCurrentUser } from "@/lib/auth";
4+
import { decryptWebhookUrl } from "@/lib/encryption";
5+
6+
/**
7+
* User Data API
8+
*
9+
* GET /api/user/export - GDPR data export (all user data)
10+
* DELETE /api/user - Account deletion (removes all user data)
11+
*/
12+
13+
export async function GET() {
14+
try {
15+
const userId = await getUserId();
16+
17+
if (!userId) {
18+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
19+
}
20+
21+
const supabase = createServerClient();
22+
23+
// Fetch all user data
24+
const [
25+
{ data: user },
26+
{ data: competitors },
27+
{ data: alerts },
28+
{ data: snapshots }
29+
] = await Promise.all([
30+
supabase.from("users").select("*").eq("id", userId).single(),
31+
supabase.from("competitors").select("*").eq("user_id", userId),
32+
supabase.from("alerts").select("*, competitors!inner(user_id)").eq("competitors.user_id", userId),
33+
supabase.from("snapshots").select("*, competitors!inner(user_id)").eq("competitors.user_id", userId)
34+
]);
35+
36+
if (!user) {
37+
return NextResponse.json({ error: "User not found" }, { status: 404 });
38+
}
39+
40+
// Sanitize settings (decrypt and redact sensitive data)
41+
const sanitizedSettings = user.settings ? {
42+
...user.settings,
43+
slack_webhook_url: user.settings.slack_webhook_url
44+
? "[REDACTED - Encrypted Webhook URL]"
45+
: null
46+
} : null;
47+
48+
// Build export payload
49+
const exportData = {
50+
exportedAt: new Date().toISOString(),
51+
user: {
52+
id: user.id,
53+
email: user.email,
54+
plan: user.plan,
55+
subscription_status: user.subscription_status,
56+
created_at: user.created_at,
57+
settings: sanitizedSettings
58+
},
59+
competitors: (competitors || []).map(c => ({
60+
id: c.id,
61+
name: c.name,
62+
url: c.url,
63+
status: c.status,
64+
created_at: c.created_at,
65+
last_checked_at: c.last_checked_at
66+
})),
67+
alerts: (alerts || []).map(a => ({
68+
id: a.id,
69+
type: a.type,
70+
severity: a.severity,
71+
title: a.title,
72+
description: a.description,
73+
created_at: a.created_at,
74+
is_read: a.is_read
75+
})),
76+
snapshots_count: snapshots?.length || 0,
77+
// Don't include full snapshot data (too large), just metadata
78+
snapshots_summary: (snapshots || []).slice(0, 10).map(s => ({
79+
id: s.id,
80+
competitor_id: s.competitor_id,
81+
created_at: s.created_at,
82+
region: s.region
83+
}))
84+
};
85+
86+
// Return as downloadable JSON
87+
return new NextResponse(JSON.stringify(exportData, null, 2), {
88+
status: 200,
89+
headers: {
90+
"Content-Type": "application/json",
91+
"Content-Disposition": `attachment; filename="rivaleye-export-${userId}.json"`
92+
}
93+
});
94+
} catch (error) {
95+
console.error("Data export error:", error);
96+
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
97+
}
98+
}
99+
100+
export async function DELETE() {
101+
try {
102+
const userId = await getUserId();
103+
104+
if (!userId) {
105+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
106+
}
107+
108+
const supabase = createServerClient();
109+
110+
// Cascade delete: snapshots → alerts → competitors → user
111+
// Note: In production, you'd want RLS and proper cascade constraints
112+
113+
// 1. Get all competitor IDs for this user
114+
const { data: competitors } = await supabase
115+
.from("competitors")
116+
.select("id")
117+
.eq("user_id", userId);
118+
119+
const competitorIds = (competitors || []).map(c => c.id);
120+
121+
if (competitorIds.length > 0) {
122+
// 2. Delete snapshots for these competitors
123+
await supabase
124+
.from("snapshots")
125+
.delete()
126+
.in("competitor_id", competitorIds);
127+
128+
// 3. Delete alerts for these competitors
129+
await supabase
130+
.from("alerts")
131+
.delete()
132+
.in("competitor_id", competitorIds);
133+
134+
// 4. Delete competitors
135+
await supabase
136+
.from("competitors")
137+
.delete()
138+
.eq("user_id", userId);
139+
}
140+
141+
// 5. Delete user record
142+
const { error: userDeleteError } = await supabase
143+
.from("users")
144+
.delete()
145+
.eq("id", userId);
146+
147+
if (userDeleteError) {
148+
console.error("User deletion error:", userDeleteError);
149+
return NextResponse.json({ error: "Failed to delete account" }, { status: 500 });
150+
}
151+
152+
// Log the deletion event
153+
console.log(`[GDPR] User account deleted: ${userId}`);
154+
155+
return NextResponse.json({
156+
success: true,
157+
message: "Account and all associated data have been permanently deleted."
158+
});
159+
} catch (error) {
160+
console.error("Account deletion error:", error);
161+
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
162+
}
163+
}

src/app/components/Header.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export default function Header() {
9898
pathname === "/settings" && "text-emerald-400 bg-white/5 border-white/5"
9999
)}
100100
title="Settings"
101+
aria-label="Open notification settings"
101102
>
102103
<SettingsIcon className="w-4.5 h-4.5" />
103104
</Button>

0 commit comments

Comments
 (0)