refactor: replace all explicit any types with proper TypeScript types

Backend resolvers: typed enrichArticle/enrichVideo/enrichModel with DB
and $inferSelect types, SQL<unknown>[] for conditions arrays, proper
enum casts for status/role fields, $inferInsert for .set() updates,
typed raw SQL result rows in gamification, ReplyLike interface for
ctx.reply in auth. Frontend: typed catch blocks with Error/interface
casts, isActiveLink param, adminGetUser response, tags filter callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 19:25:04 +01:00
parent 8313664d70
commit e236ced12a
30 changed files with 392 additions and 375 deletions

View File

@@ -1,10 +1,11 @@
import { builder } from "../builder";
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
import { articles, users } from "../../db/schema/index";
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains } from "drizzle-orm";
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains, type SQL } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
import type { DB } from "../../db/connection";
async function enrichArticle(db: any, article: any) {
async function enrichArticle(db: DB, article: typeof articles.$inferSelect) {
let author = null;
if (article.author) {
const authorUser = await db
@@ -39,7 +40,7 @@ builder.queryField("articles", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [lte(articles.publish_date, new Date())];
const conditions: SQL<unknown>[] = [lte(articles.publish_date, new Date())];
if (args.featured !== null && args.featured !== undefined) {
conditions.push(eq(articles.featured, args.featured));
}
@@ -50,28 +51,24 @@ builder.queryField("articles", (t) =>
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
),
) as SQL<unknown>,
);
}
const orderArgs =
args.sortBy === "name"
? [asc(articles.title)]
: args.sortBy === "featured"
? [desc(articles.featured), desc(articles.publish_date)]
: [desc(articles.publish_date)];
const where = and(...conditions);
const baseQuery = ctx.db.select().from(articles).where(where);
const ordered =
args.sortBy === "name"
? baseQuery.orderBy(asc(articles.title))
: args.sortBy === "featured"
? baseQuery.orderBy(desc(articles.featured), desc(articles.publish_date))
: baseQuery.orderBy(desc(articles.publish_date));
const [articleList, totalRows] = await Promise.all([
(ctx.db.select().from(articles).where(where) as any)
.orderBy(...orderArgs)
.limit(pageSize)
.offset(offset),
ordered.limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(
articleList.map((article: any) => enrichArticle(ctx.db, article)),
);
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -130,13 +127,13 @@ builder.queryField("adminListArticles", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.search) {
conditions.push(
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
),
) as SQL<unknown>,
);
}
if (args.category) conditions.push(eq(articles.category, args.category));
@@ -154,9 +151,7 @@ builder.queryField("adminListArticles", (t) =>
.offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(
articleList.map((article: any) => enrichArticle(ctx.db, article)),
);
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -232,7 +227,7 @@ builder.mutationField("updateArticle", (t) =>
const updated = await ctx.db
.update(articles)
.set(updates as any)
.set(updates as Partial<typeof articles.$inferInsert>)
.where(eq(articles.id, args.id))
.returning();
if (!updated[0]) return null;

View File

@@ -3,6 +3,10 @@ import { builder } from "../builder";
import { CurrentUserType } from "../types/index";
import { users } from "../../db/schema/index";
import { eq } from "drizzle-orm";
interface ReplyLike {
header?: (name: string, value: string) => void;
}
import { hash, verify as verifyArgon } from "../../lib/argon";
import { setSession, deleteSession } from "../../lib/auth";
import { sendVerification, sendPasswordReset } from "../../lib/email";
@@ -46,12 +50,7 @@ builder.mutationField("login", (t) =>
// Set session cookie
const isProduction = process.env.NODE_ENV === "production";
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400${isProduction ? "; Secure" : ""}`;
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
// For graphql-yoga response
if ((ctx as any).serverResponse) {
(ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue);
}
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
return user[0];
},
@@ -76,7 +75,7 @@ builder.mutationField("logout", (t) =>
// Clear cookie
const isProduction = process.env.NODE_ENV === "production";
const cookieValue = `session_token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isProduction ? "; Secure" : ""}`;
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
return true;
},
}),

View File

@@ -20,7 +20,7 @@ builder.queryField("commentsForVideo", (t) =>
.orderBy(desc(comments.date_created));
return Promise.all(
commentList.map(async (c: any) => {
commentList.map(async (c) => {
const user = await ctx.db
.select({
id: users.id,
@@ -125,7 +125,7 @@ builder.queryField("adminListComments", (t) =>
]);
const items = await Promise.all(
commentList.map(async (c: any) => {
commentList.map(async (c) => {
const user = await ctx.db
.select({
id: users.id,

View File

@@ -37,7 +37,7 @@ builder.queryField("leaderboard", (t) =>
.limit(limit)
.offset(offset);
return entries.map((e: any, i: number) => ({ ...e, rank: offset + i + 1 }));
return entries.map((e, i) => ({ ...e, rank: offset + i + 1 }));
},
}),
);
@@ -101,7 +101,7 @@ builder.queryField("userGamification", (t) =>
return {
stats: stats[0] ? { ...stats[0], rank } : null,
achievements: userAchievements.map((a: any) => ({
achievements: userAchievements.map((a) => ({
...a,
date_unlocked: a.date_unlocked!,
})),

View File

@@ -1,9 +1,10 @@
import { builder } from "../builder";
import { ModelType, ModelListType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, and, desc, asc, ilike, count, arrayContains } from "drizzle-orm";
import { eq, and, desc, asc, ilike, count, arrayContains, type SQL } from "drizzle-orm";
import type { DB } from "../../db/connection";
async function enrichModel(db: any, user: any) {
async function enrichModel(db: DB, user: typeof users.$inferSelect) {
// Fetch photos
const photoRows = await db
.select({ id: files.id, filename: files.filename })
@@ -14,8 +15,8 @@ async function enrichModel(db: any, user: any) {
const seen = new Set<string>();
const photos = photoRows
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p: any) => ({ id: p.id, filename: p.filename }));
.filter((p) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p) => ({ id: p.id, filename: p.filename }));
return { ...user, photos };
}
@@ -35,7 +36,7 @@ builder.queryField("models", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [eq(users.role, "model")];
const conditions: SQL<unknown>[] = [eq(users.role, "model")];
if (args.search) conditions.push(ilike(users.artist_name, `%${args.search}%`));
if (args.tag) conditions.push(arrayContains(users.tags, [args.tag]));
@@ -46,7 +47,7 @@ builder.queryField("models", (t) =>
ctx.db.select().from(users).where(where).orderBy(order).limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(users).where(where),
]);
const items = await Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
const items = await Promise.all(modelList.map((m) => enrichModel(ctx.db, m)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),

View File

@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { RecordingType, AdminRecordingListType } from "../types/index";
import { recordings, recording_plays } from "../../db/schema/index";
import { eq, and, desc, ilike, count } from "drizzle-orm";
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
import { slugify } from "../../lib/slugify";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireAdmin } from "../../lib/acl";
@@ -21,7 +21,7 @@ builder.queryField("recordings", (t) =>
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
if (args.status) conditions.push(eq(recordings.status, args.status as any));
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
const limit = args.limit || 50;
@@ -115,7 +115,7 @@ builder.mutationField("createRecording", (t) =>
user_id: ctx.currentUser.id,
tags: args.tags || [],
linked_video: args.linkedVideoId || null,
status: (args.status as any) || "draft",
status: (args.status as "draft" | "published") || "draft",
public: false,
})
.returning();
@@ -174,7 +174,7 @@ builder.mutationField("updateRecording", (t) =>
const updated = await ctx.db
.update(recordings)
.set(updates as any)
.set(updates as Partial<typeof recordings.$inferInsert>)
.where(eq(recordings.id, args.id))
.returning();
@@ -355,9 +355,9 @@ builder.queryField("adminListRecordings", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.search) conditions.push(ilike(recordings.title, `%${args.search}%`));
if (args.status) conditions.push(eq(recordings.status, args.status as any));
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totalRows] = await Promise.all([

View File

@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, ilike, or, count, and } from "drizzle-orm";
import { eq, ilike, or, count, and, asc, type SQL } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
builder.queryField("me", (t) =>
@@ -63,7 +63,7 @@ builder.mutationField("updateProfile", (t) =>
await ctx.db
.update(users)
.set(updates as any)
.set(updates as Partial<typeof users.$inferInsert>)
.where(eq(users.id, ctx.currentUser.id));
const updated = await ctx.db
@@ -93,27 +93,27 @@ builder.queryField("adminListUsers", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
let query = ctx.db.select().from(users);
let countQuery = ctx.db.select({ total: count() }).from(users);
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.role) {
conditions.push(eq(users.role, args.role as any));
conditions.push(eq(users.role, args.role as "model" | "viewer" | "admin"));
}
if (args.search) {
const pattern = `%${args.search}%`;
conditions.push(or(ilike(users.email, pattern), ilike(users.artist_name, pattern)));
}
if (conditions.length > 0) {
const where = conditions.length === 1 ? conditions[0] : and(...conditions);
query = (query as any).where(where);
countQuery = (countQuery as any).where(where);
conditions.push(
or(ilike(users.email, pattern), ilike(users.artist_name, pattern)) as SQL<unknown>,
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [items, totalRows] = await Promise.all([
(query as any).limit(limit).offset(offset),
countQuery,
ctx.db
.select()
.from(users)
.where(where)
.orderBy(asc(users.artist_name))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(users).where(where),
]);
return { items, total: totalRows[0]?.total ?? 0 };
@@ -139,7 +139,8 @@ builder.mutationField("adminUpdateUser", (t) =>
requireAdmin(ctx);
const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.role !== undefined && args.role !== null) updates.role = args.role as any;
if (args.role !== undefined && args.role !== null)
updates.role = args.role as "model" | "viewer" | "admin";
if (args.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin;
if (args.firstName !== undefined && args.firstName !== null)
updates.first_name = args.firstName;
@@ -151,7 +152,7 @@ builder.mutationField("adminUpdateUser", (t) =>
const updated = await ctx.db
.update(users)
.set(updates as any)
.set(updates as Partial<typeof users.$inferInsert>)
.where(eq(users.id, args.userId))
.returning();
@@ -194,8 +195,8 @@ builder.queryField("adminGetUser", (t) =>
.orderBy(user_photos.sort);
const seen = new Set<string>();
const photos = photoRows
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p: any) => ({ id: p.id, filename: p.filename }));
.filter((p) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p) => ({ id: p.id, filename: p.filename }));
return { ...user[0], photos };
},
}),

View File

@@ -28,10 +28,12 @@ import {
lt,
gte,
arrayContains,
type SQL,
} from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
import type { DB } from "../../db/connection";
async function enrichVideo(db: any, video: any) {
async function enrichVideo(db: DB, video: typeof videos.$inferSelect) {
// Fetch models
const modelRows = await db
.select({
@@ -87,7 +89,7 @@ builder.queryField("videos", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [lte(videos.upload_date, new Date())];
const conditions: SQL<unknown>[] = [lte(videos.upload_date, new Date())];
if (!ctx.currentUser) conditions.push(eq(videos.premium, false));
if (args.featured !== null && args.featured !== undefined) {
conditions.push(eq(videos.featured, args.featured));
@@ -107,7 +109,7 @@ builder.queryField("videos", (t) =>
conditions.push(
inArray(
videos.id,
videoIds.map((v: any) => v.video_id),
videoIds.map((v) => v.video_id),
),
);
}
@@ -148,8 +150,8 @@ builder.queryField("videos", (t) =>
.leftJoin(files, eq(videos.movie, files.id))
.where(fullWhere),
]);
const videoList = rows.map((r: any) => r.v || r);
const items = await Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
const videoList = rows.map((r) => r.v);
const items = await Promise.all(videoList.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
}
@@ -157,7 +159,7 @@ builder.queryField("videos", (t) =>
ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -421,7 +423,7 @@ builder.queryField("analytics", (t) =>
};
}
const videoIds = modelVideoIds.map((v: any) => v.video_id);
const videoIds = modelVideoIds.map((v) => v.video_id);
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
const plays = await ctx.db
.select()
@@ -435,14 +437,14 @@ builder.queryField("analytics", (t) =>
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
const playsByDate = plays.reduce((acc: any, play) => {
const playsByDate = plays.reduce((acc: Record<string, number>, play) => {
const date = new Date(play.date_created).toISOString().split("T")[0];
if (!acc[date]) acc[date] = 0;
acc[date]++;
return acc;
}, {});
const likesByDate = likes.reduce((acc: any, like) => {
const likesByDate = likes.reduce((acc: Record<string, number>, like) => {
const date = new Date(like.date_created).toISOString().split("T")[0];
if (!acc[date]) acc[date] = 0;
acc[date]++;
@@ -499,7 +501,7 @@ builder.queryField("adminListVideos", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.search) conditions.push(ilike(videos.title, `%${args.search}%`));
if (args.premium !== null && args.premium !== undefined)
conditions.push(eq(videos.premium, args.premium));
@@ -517,7 +519,7 @@ builder.queryField("adminListVideos", (t) =>
.offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -590,7 +592,7 @@ builder.mutationField("updateVideo", (t) =>
const updated = await ctx.db
.update(videos)
.set(updates as any)
.set(updates as Partial<typeof videos.$inferInsert>)
.where(eq(videos.id, args.id))
.returning();
if (!updated[0]) return null;

View File

@@ -47,7 +47,7 @@ export async function calculateWeightedScore(db: DB, userId: string): Promise<nu
FROM user_points
WHERE user_id = ${userId}
`);
return parseFloat((result.rows[0] as any)?.weighted_score || "0");
return parseFloat((result.rows[0] as { weighted_score?: string })?.weighted_score || "0");
}
export async function updateUserStats(db: DB, userId: string): Promise<void> {
@@ -84,7 +84,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
sql`, `,
)})
`);
playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0");
playbacksCount = parseInt((playbacksResult.rows[0] as { count?: string })?.count || "0");
} else {
const playbacksResult = await db
.select({ count: count() })
@@ -242,7 +242,7 @@ async function getAchievementProgress(
WHERE rp.user_id = ${userId}
AND r.user_id != ${userId}
`);
return parseInt((result.rows[0] as any)?.count || "0");
return parseInt((result.rows[0] as { count?: string })?.count || "0");
}
if (["completionist_10", "completionist_100"].includes(code)) {
@@ -293,7 +293,7 @@ async function getAchievementProgress(
WHERE rp.user_id = ${userId} AND r.user_id != ${userId}
`);
const rc = recordingsResult[0]?.count || 0;
const pc = parseInt((playsResult.rows[0] as any)?.count || "0");
const pc = parseInt((playsResult.rows[0] as { count?: string })?.count || "0");
return rc >= 50 && pc >= 100 ? 1 : 0;
}