Skip to content

Commit e8d8971

Browse files
authored
Merge pull request #844 from Pvsaint/feat/issues-771-773-781-782-accessibility-seo-reporting
feat: accessibility, SEO, and profile reporting (closes #771, #773, #…
2 parents 8ed93e6 + 9c494b9 commit e8d8971

13 files changed

Lines changed: 616 additions & 124 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- CreateTable
2+
CREATE TABLE "profile_reports" (
3+
"id" TEXT NOT NULL,
4+
"profileId" TEXT NOT NULL,
5+
"reason" TEXT NOT NULL,
6+
"details" TEXT,
7+
"reporterIp" TEXT NOT NULL,
8+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
10+
CONSTRAINT "profile_reports_pkey" PRIMARY KEY ("id")
11+
);
12+
13+
-- CreateIndex
14+
CREATE INDEX "profile_reports_profileId_idx" ON "profile_reports"("profileId");
15+
16+
-- CreateIndex
17+
CREATE INDEX "profile_reports_profileId_createdAt_idx" ON "profile_reports"("profileId", "createdAt" DESC);
18+
19+
-- AddForeignKey
20+
ALTER TABLE "profile_reports" ADD CONSTRAINT "profile_reports_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;

backend/prisma/schema.prisma

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ model Profile {
4545
milestones Milestone[]
4646
webhooks Webhook[]
4747
notificationPreferences NotificationPreferences?
48+
reports ProfileReport[]
4849
4950
@@index([createdAt(sort: Desc)])
5051
@@index([ownerId])
@@ -262,3 +263,17 @@ model CircuitBreakerState {
262263
263264
@@map("circuit_breaker_states")
264265
}
266+
267+
model ProfileReport {
268+
id String @id @default(cuid())
269+
profileId String
270+
reason String
271+
details String?
272+
reporterIp String
273+
createdAt DateTime @default(now())
274+
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
275+
276+
@@index([profileId])
277+
@@index([profileId, createdAt(sort: Desc)])
278+
@@map("profile_reports")
279+
}

backend/src/app.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3998,9 +3998,88 @@ All errors return JSON with an \`error\` field and optional \`code\`:
39983998
}
39993999
});
40004000

4001-
// ── Supporters ─────────────────────────────────────────────────────────
4001+
// ── Profile Reports (#771) ─────────────────────────────────────────────
4002+
4003+
// Rate limiter: 1 report per IP per profile per hour
4004+
const reportLimiter = rateLimit({
4005+
windowMs: 60 * 60 * 1000,
4006+
limit: 1,
4007+
standardHeaders: true,
4008+
legacyHeaders: false,
4009+
skip: () => process.env.NODE_ENV === "test",
4010+
keyGenerator: (req) => `${req.ip}-${req.params.username}`,
4011+
message: {
4012+
error: "You have already reported this profile. Please wait an hour before submitting another report.",
4013+
code: "REPORT_RATE_LIMIT_EXCEEDED",
4014+
},
4015+
});
4016+
4017+
const reportSchema = z.object({
4018+
reason: z.enum(["spam", "impersonation", "inappropriate", "scam"]),
4019+
details: z.string().max(500).optional(),
4020+
});
4021+
4022+
v1Router.post("/profiles/:username/report", reportLimiter, async (req, res) => {
4023+
try {
4024+
const { username } = req.params as { username: string };
4025+
const profile = await prisma.profile.findUnique({
4026+
where: { username },
4027+
select: { id: true, username: true },
4028+
});
4029+
4030+
if (!profile) {
4031+
return sendError(res, 404, "Profile not found");
4032+
}
4033+
4034+
const parsed = reportSchema.safeParse(req.body);
4035+
if (!parsed.success) {
4036+
return sendError(res, 400, "Invalid request body: reason must be one of spam, impersonation, inappropriate, scam");
4037+
}
4038+
4039+
const reporterIp = req.ip ?? "unknown";
4040+
4041+
await (prisma as any).profileReport.create({
4042+
data: {
4043+
profileId: profile.id,
4044+
reason: parsed.data.reason,
4045+
details: parsed.data.details ?? null,
4046+
reporterIp,
4047+
},
4048+
});
4049+
4050+
// Check if this profile has accumulated 3+ reports and alert admin
4051+
const reportCount = await (prisma as any).profileReport.count({
4052+
where: { profileId: profile.id },
4053+
});
40024054

4003-
v1Router.get("/supporters/:address", async (req, res) => {
4055+
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
4056+
if (reportCount >= 3 && ADMIN_EMAIL) {
4057+
try {
4058+
const { sendEmail } = await import("./mailer.js");
4059+
await sendEmail({
4060+
to: ADMIN_EMAIL,
4061+
subject: `[NovaSupport] Profile @${username} has ${reportCount} report(s)`,
4062+
html: `
4063+
<p>Profile <strong>@${username}</strong> has accumulated <strong>${reportCount}</strong> report(s).</p>
4064+
<p>Latest report reason: <strong>${parsed.data.reason}</strong></p>
4065+
${parsed.data.details ? `<p>Details: ${parsed.data.details}</p>` : ""}
4066+
<p>Please review this profile in the admin panel.</p>
4067+
`,
4068+
});
4069+
} catch (emailErr) {
4070+
// Don't fail the request if email fails — just log it
4071+
logger.warn({ err: emailErr, username }, "failed to send admin alert email for profile report");
4072+
}
4073+
}
4074+
4075+
return res.status(201).json({ message: "Report submitted successfully." });
4076+
} catch (e: unknown) {
4077+
logger.error({ err: e }, "failed to submit profile report");
4078+
return sendError(res, 500, "Internal server error");
4079+
}
4080+
});
4081+
4082+
// ── Supporters ─────────────────────────────────────────────────────────
40044083
try {
40054084
const { address } = req.params;
40064085

frontend/package-lock.json

Lines changed: 32 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@stellar/stellar-sdk": "^13.3.0",
1717
"@tanstack/react-query": "^5.101.4",
1818
"clsx": "^2.1.1",
19+
"focus-trap-react": "^10.3.1",
1920
"framer-motion": "^12.38.0",
2021
"lucide-react": "^1.7.0",
2122
"next": "14.2.35",

frontend/src/app/dashboard/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,13 +562,15 @@ export default function DashboardPage() {
562562
<div className="flex gap-2">
563563
<button
564564
onClick={() => handleEditMilestone(milestone)}
565+
aria-label={`Edit milestone: ${milestone.title}`}
565566
className="min-h-[44px] min-w-[44px] rounded-lg bg-white/5 p-2 text-steel hover:bg-white/10 transition-colors"
566567
title="Edit"
567568
>
568569
<Edit2 size={14} />
569570
</button>
570571
<button
571572
onClick={() => setDeleteConfirm(milestone.id)}
573+
aria-label={`Delete milestone: ${milestone.title}`}
572574
className="min-h-[44px] min-w-[44px] rounded-lg bg-white/5 p-2 text-red-400 hover:bg-red-500/10 transition-colors"
573575
title="Delete"
574576
>
@@ -752,13 +754,15 @@ export default function DashboardPage() {
752754
<div className="flex gap-2">
753755
<button
754756
onClick={() => handleToggleDeliveries(webhook.id)}
757+
aria-label={expandedDeliveries === webhook.id ? "Hide webhook deliveries" : "View webhook deliveries"}
755758
className="rounded-lg bg-white/5 p-2 text-steel hover:bg-white/10 transition-colors"
756759
title="View deliveries"
757760
>
758761
{expandedDeliveries === webhook.id ? <EyeOff size={14} /> : <Eye size={14} />}
759762
</button>
760763
<button
761764
onClick={() => setWebhookDeleteConfirm(webhook.id)}
765+
aria-label={`Delete webhook for ${webhook.url}`}
762766
className="rounded-lg bg-white/5 p-2 text-red-400 hover:bg-red-500/10 transition-colors"
763767
title="Delete"
764768
>

frontend/src/app/profile/[username]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { EmbedCodeGenerator } from "@/components/embed-widget";
1212
import { MilestoneCard } from "@/components/milestone-card";
1313
import { ActivityFeed } from "@/components/activity-feed";
1414
import { EditProfileButton } from "@/components/edit-profile-button";
15+
import { ReportProfileModal } from "@/components/report-profile-modal";
1516
import { API_BASE_URL, SITE_URL } from "@/lib/config";
1617
import { stellarExpertUrl } from "@/lib/stellar";
1718

@@ -274,6 +275,7 @@ export default async function ProfilePage({ params }: PageProps) {
274275
<RSSFeedButton username={profile.username} />
275276
<ShareButton displayName={profile.displayName} username={profile.username} />
276277
<EditProfileButton username={profile.username} walletAddress={profile.walletAddress} />
278+
<ReportProfileModal username={profile.username} displayName={profile.displayName} />
277279
</div>
278280
</div>
279281

frontend/src/app/robots.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { MetadataRoute } from "next";
2+
import { SITE_URL } from "@/lib/config";
3+
4+
export default function robots(): MetadataRoute.Robots {
5+
return {
6+
rules: [
7+
{
8+
userAgent: "*",
9+
allow: "/",
10+
disallow: ["/dashboard", "/settings", "/embed"],
11+
},
12+
],
13+
sitemap: `${SITE_URL}/sitemap.xml`,
14+
};
15+
}

frontend/src/app/sitemap.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { MetadataRoute } from "next";
2+
import { API_BASE_URL, SITE_URL } from "@/lib/config";
3+
4+
type ProfileListItem = {
5+
username: string;
6+
updatedAt?: string;
7+
};
8+
9+
type ProfilesResponse = {
10+
profiles: ProfileListItem[];
11+
};
12+
13+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
14+
// Static high-priority pages
15+
const staticEntries: MetadataRoute.Sitemap = [
16+
{
17+
url: `${SITE_URL}/`,
18+
lastModified: new Date(),
19+
changeFrequency: "daily",
20+
priority: 1.0,
21+
},
22+
{
23+
url: `${SITE_URL}/explore`,
24+
lastModified: new Date(),
25+
changeFrequency: "daily",
26+
priority: 1.0,
27+
},
28+
];
29+
30+
// Dynamic creator profile pages
31+
let profileEntries: MetadataRoute.Sitemap = [];
32+
try {
33+
const res = await fetch(`${API_BASE_URL}/v1/profiles?limit=1000`, {
34+
next: { revalidate: 3600 }, // revalidate once per hour
35+
});
36+
if (res.ok) {
37+
const data: ProfilesResponse = await res.json();
38+
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
39+
profileEntries = profiles.map((profile) => ({
40+
url: `${SITE_URL}/profile/${profile.username}`,
41+
lastModified: profile.updatedAt ? new Date(profile.updatedAt) : new Date(),
42+
changeFrequency: "weekly" as const,
43+
priority: 0.8,
44+
}));
45+
}
46+
} catch {
47+
// Sitemap generation should not fail the build if the API is unavailable
48+
}
49+
50+
return [...staticEntries, ...profileEntries];
51+
}

0 commit comments

Comments
 (0)