1. Wrap anonymizeUserAccount in a DB transaction so partial failures (e.g. session delete succeeds but user update fails) can't leave accounts in an inconsistent state 2. Escape user email and notes with escapeHtml() before interpolating into the data request email body 3. Reset AlertDialog confirmed state when dialog is dismissed via backdrop click or Escape key (onOpenChange handler) 4. Extract accounts DB query to app/models/account.ts (findLinkedAccountsByUserId) to comply with the "always query through app/models/" convention 5. Replace dynamic imports() in action handlers with static top-level imports 6. Remove the unused hard-delete deleteUser() function 7. Add lastDataRequestAt timestamp to users (migration 0101) and enforce a 30-day server-side cooldown on data export requests 8. Replace fragile actionData type casts with a proper ActionData discriminated union; narrowing now works without `as` assertions 9. Strengthen tests: verify which schema tables are passed to delete() and that operations run inside the transaction https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X
119 lines
3.4 KiB
TypeScript
119 lines
3.4 KiB
TypeScript
import { eq, inArray, and } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { generateFlagConfig } from "~/lib/flag-generator";
|
|
|
|
export type User = typeof schema.users.$inferSelect;
|
|
export type NewUser = typeof schema.users.$inferInsert;
|
|
|
|
export function getUserDisplayName(
|
|
user: Pick<User, "username" | "displayName">
|
|
): string | null {
|
|
return user.username ?? user.displayName ?? null;
|
|
}
|
|
|
|
export async function createUser(data: NewUser): Promise<User> {
|
|
const db = database();
|
|
const id = data.id ?? crypto.randomUUID();
|
|
const [user] = await db
|
|
.insert(schema.users)
|
|
.values({ ...data, id, flagConfig: data.flagConfig ?? generateFlagConfig(id) })
|
|
.returning();
|
|
return user;
|
|
}
|
|
|
|
export async function findUserById(id: string): Promise<User | undefined> {
|
|
const db = database();
|
|
return await db.query.users.findFirst({
|
|
where: eq(schema.users.id, id),
|
|
});
|
|
}
|
|
|
|
export async function findUsersByIds(ids: string[]): Promise<User[]> {
|
|
if (ids.length === 0) return [];
|
|
const db = database();
|
|
return await db
|
|
.select()
|
|
.from(schema.users)
|
|
.where(inArray(schema.users.id, ids));
|
|
}
|
|
|
|
export async function updateUser(
|
|
id: string,
|
|
data: Partial<NewUser>
|
|
): Promise<User> {
|
|
const db = database();
|
|
const [user] = await db
|
|
.update(schema.users)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(schema.users.id, id))
|
|
.returning();
|
|
return user;
|
|
}
|
|
|
|
export async function anonymizeUserAccount(id: string): Promise<void> {
|
|
const db = database();
|
|
const shortId = id.replace(/-/g, "").slice(0, 8);
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(schema.sessions).where(eq(schema.sessions.userId, id));
|
|
await tx.delete(schema.accounts).where(eq(schema.accounts.userId, id));
|
|
await tx
|
|
.update(schema.users)
|
|
.set({
|
|
email: `deleted-${shortId}@deleted.brackt.com`,
|
|
displayName: null,
|
|
username: null,
|
|
imageUrl: null,
|
|
customAvatarUrl: null,
|
|
flagConfig: null,
|
|
avatarType: "flag",
|
|
deletedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.users.id, id));
|
|
});
|
|
}
|
|
|
|
export async function findAdmins(): Promise<User[]> {
|
|
const db = database();
|
|
return await db.query.users.findMany({
|
|
where: eq(schema.users.isAdmin, true),
|
|
orderBy: (users, { asc }) => [asc(users.displayName)],
|
|
});
|
|
}
|
|
|
|
export async function isUserAdmin(userId: string): Promise<boolean> {
|
|
const user = await findUserById(userId);
|
|
return user?.isAdmin ?? false;
|
|
}
|
|
|
|
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> {
|
|
return await updateUser(userId, { isAdmin });
|
|
}
|
|
|
|
export async function findAllUsers(): Promise<User[]> {
|
|
const db = database();
|
|
return await db.query.users.findMany({
|
|
orderBy: (users, { asc }) => [asc(users.displayName)],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns true if the user currently owns a team in a draft-status season.
|
|
* Used to prevent timezone changes mid-draft.
|
|
*/
|
|
export async function isUserInActiveDraft(userId: string): Promise<boolean> {
|
|
const db = database();
|
|
const result = await db
|
|
.select({ id: schema.teams.id })
|
|
.from(schema.teams)
|
|
.innerJoin(schema.seasons, eq(schema.teams.seasonId, schema.seasons.id))
|
|
.where(
|
|
and(
|
|
eq(schema.teams.ownerId, userId),
|
|
eq(schema.seasons.status, "draft")
|
|
)
|
|
)
|
|
.limit(1);
|
|
return result.length > 0;
|
|
}
|