Profile page UI and functionality implemented

This commit is contained in:
adilallo
2026-04-25 17:57:58 -06:00
parent 7dd2562bae
commit 68517796a9
103 changed files with 4439 additions and 1476 deletions
+210
View File
@@ -207,3 +207,213 @@ export async function publishRule(input: {
};
}
}
export type MyPublishedRule = {
id: string;
title: string;
summary: string | null;
createdAt: string;
updatedAt: string;
};
/**
* Lists the signed-in users published rules (newest first). Returns `null` on
* network failure or unauthenticated response.
*/
export async function fetchMyPublishedRules(): Promise<
MyPublishedRule[] | null
> {
try {
const res = await fetch("/api/rules/me", { credentials: "include" });
if (res.status === 401) return null;
if (!res.ok) return null;
const data = (await safeParseJsonResponse(res)) as {
rules?: MyPublishedRule[];
} | null;
if (!data || !Array.isArray(data.rules)) return null;
return data.rules;
} catch {
return null;
}
}
export type PublishedRuleDetailForClient = {
id: string;
title: string;
summary: string | null;
document: unknown;
};
export type FetchPublishedRuleDetailResult = {
rule: PublishedRuleDetailForClient;
viewerIsOwner: boolean;
};
/**
* Fetches a published rule for the browser (credentials included).
* Returns `null` on network failure or non-OK response.
*/
export async function fetchPublishedRuleDetail(
id: string,
): Promise<FetchPublishedRuleDetailResult | null> {
try {
const res = await fetch(`/api/rules/${encodeURIComponent(id)}`, {
credentials: "include",
});
if (!res.ok) return null;
const data = (await safeParseJsonResponse(res)) as {
rule?: PublishedRuleDetailForClient;
viewerIsOwner?: unknown;
} | null;
if (
!data ||
!data.rule ||
typeof data.rule.id !== "string" ||
typeof data.rule.title !== "string" ||
typeof data.viewerIsOwner !== "boolean"
) {
return null;
}
return { rule: data.rule, viewerIsOwner: data.viewerIsOwner };
} catch {
return null;
}
}
export type DeleteRuleResult =
| { ok: true }
| { ok: false; error: string; status: number };
export async function deletePublishedRule(
id: string,
): Promise<DeleteRuleResult> {
try {
const res = await fetch(`/api/rules/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) {
return { ok: true as const };
}
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DuplicateRuleResult =
| { ok: true; id: string; title: string }
| { ok: false; error: string; status: number };
export async function duplicatePublishedRule(
id: string,
): Promise<DuplicateRuleResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(id)}/duplicate`,
{
method: "POST",
credentials: "include",
},
);
const data = (await safeParseJsonResponse(res)) as {
rule?: { id: string; title: string };
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
if (!res.ok || !rule) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true, id: rule.id, title: rule.title };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DeleteAccountResult = { ok: true } | { ok: false; error: string };
/**
* Permanently deletes the signed-in user. Caller should redirect and refresh UI.
*/
export async function deleteAccount(): Promise<DeleteAccountResult> {
try {
const res = await fetch("/api/user/me", {
method: "DELETE",
credentials: "include",
});
if (res.ok) {
return { ok: true as const };
}
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
};
}
}
export type ServerDraftForProfile =
| { hasDraft: false }
| { hasDraft: true; updatedAt: string; state: CreateFlowState };
/**
* Fetches the signed-in users server draft for the profile page. Returns
* `null` on auth/transport failure.
*/
export async function fetchServerDraftForProfile(): Promise<
ServerDraftForProfile | null
> {
try {
const res = await fetch("/api/drafts/me", { credentials: "include" });
if (res.status === 401) return null;
if (!res.ok) return null;
const data = (await parseJson(res)) as {
draft: { payload: unknown; updatedAt: string } | null;
};
if (!data.draft) {
return { hasDraft: false };
}
const payload = data.draft.payload;
const state: CreateFlowState =
payload && typeof payload === "object"
? migrateLegacyCreateFlowState(
payload as Record<string, unknown>,
)
: {};
const rawUpdated = data.draft.updatedAt;
const updatedAt =
typeof rawUpdated === "string"
? rawUpdated
: new Date().toISOString();
return { hasDraft: true, updatedAt, state };
} catch {
return null;
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Bridges final-review → completed without query strings.
* Replace with GET /api/rules/[id] (CR-81) when public rule fetch exists.
* Bridges final-review → completed without query strings, and re-opens a rule
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id].
*/
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
+41
View File
@@ -46,3 +46,44 @@ export async function getPublicPublishedRuleById(
return null;
}
}
/** Metadata for signed-in “my rules” profile list (no full `document` JSON). */
const PUBLISHED_RULE_OWNER_LIST_SELECT = {
id: true,
title: true,
summary: true,
createdAt: true,
updatedAt: true,
} as const;
export type OwnerPublishedRuleListItem = {
id: string;
title: string;
summary: string | null;
createdAt: Date;
updatedAt: Date;
};
/**
* Lists published rules owned by the given user (alphabetical by title, then id).
* Returns `null` when the database is not configured or the query throws.
*/
export async function listPublishedRulesForUser(
userId: string,
take: number,
): Promise<OwnerPublishedRuleListItem[] | null> {
if (!isDatabaseConfigured()) return null;
if (typeof userId !== "string" || userId.trim() === "") return null;
const clamped = Math.min(Math.max(0, take), 100);
if (clamped === 0) return [];
try {
return await prisma.publishedRule.findMany({
where: { userId },
orderBy: [{ title: "asc" }, { id: "asc" }],
take: clamped,
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
});
} catch {
return null;
}
}
+4
View File
@@ -62,6 +62,10 @@ export function notFound(message = "Not found"): NextResponse {
return errorJson("not_found", message, 404);
}
export function forbidden(message = "Forbidden"): NextResponse {
return errorJson("forbidden", message, 403);
}
export function rateLimited(retryAfterMs: number): NextResponse {
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
return errorJson("rate_limited", "Too many requests", 429, {
+1 -1
View File
@@ -81,7 +81,7 @@ export const createFlowStateSchema = z
.object({
title: z.string().max(500).optional(),
summary: z.string().max(8000).optional(),
communityContext: z.string().max(48).optional(),
communityContext: z.string().max(200).optional(),
communitySaveEmail: z.string().max(320).optional(),
selectedCommunitySizeIds: z.array(z.string()).optional(),
selectedOrganizationTypeIds: z.array(z.string()).optional(),