Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
445 changes: 445 additions & 0 deletions apps/api/src/dashboard/routes.test.ts

Large diffs are not rendered by default.

370 changes: 326 additions & 44 deletions apps/api/src/dashboard/routes.ts

Large diffs are not rendered by default.

183 changes: 181 additions & 2 deletions apps/api/src/db/repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { randomUUID } from "node:crypto";
import { getUniqueId } from "@medialit/utils";
import { and, asc, desc, eq, like, lt, sql } from "drizzle-orm";
import {
and,
asc,
desc,
eq,
ilike,
inArray,
like,
lt,
sql,
type SQL,
} from "drizzle-orm";
import { getDb } from "./client.js";
import {
apiKeys,
Expand Down Expand Up @@ -65,6 +76,21 @@ export type MediaRecord = {
updatedAt: Date;
};

export type ApiKeyMediaSummary = {
apikey: string;
count: number;
storage: number;
images: number;
videos: number;
pdfs: number;
others: number;
imageStorage: number;
videoStorage: number;
pdfStorage: number;
otherStorage: number;
lastUpload: Date | null;
};

export type MediaSettingsRecord = {
userId: string;
apikey: string;
Expand Down Expand Up @@ -383,6 +409,44 @@ export async function renameApiKey(input: {
.where(and(...filters));
}

export async function setDefaultApiKey(
userId: string,
keyId: string,
): Promise<void> {
const db = getDb();
await db.transaction(async (tx) => {
const [target] = await tx
.select({ id: apiKeys.id, isDefault: apiKeys.isDefault })
.from(apiKeys)
.where(
and(
eq(apiKeys.userId, userId),
eq(apiKeys.keyId, keyId),
eq(apiKeys.deleted, false),
),
)
.limit(1);
if (!target) throw new Error("Apikey not found");
if (target.isDefault) return;

const timestamp = now();
await tx
.update(apiKeys)
.set({ isDefault: false, updatedAt: timestamp })
.where(
and(
eq(apiKeys.userId, userId),
eq(apiKeys.isDefault, true),
eq(apiKeys.deleted, false),
),
);
await tx
.update(apiKeys)
.set({ isDefault: true, updatedAt: timestamp })
.where(eq(apiKeys.id, target.id));
});
}

export async function softDeleteApiKey(
userId: string,
keyId: string,
Expand Down Expand Up @@ -441,6 +505,24 @@ function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}

export type MediaKindFilter = "image" | "video" | "pdf" | "other";

/* SQL twin of mediaKind() in media/kind.ts; keep the two in step. The kinds
never overlap: image and video go by type prefix, pdf is any other type
that names pdf, and other is everything left. */
function mediaKindCondition(kind: MediaKindFilter): SQL {
const mimeType = sql`lower(${media.mimeType})`;
const isImage = sql`${mimeType} like 'image/%'`;
const isVideo = sql`${mimeType} like 'video/%'`;
const namesPdf = sql`${mimeType} like '%pdf%'`;
if (kind === "image") return sql`(${isImage})`;
if (kind === "video") return sql`(${isVideo})`;
if (kind === "pdf") {
return sql`(not (${isImage}) and not (${isVideo}) and ${namesPdf})`;
}
return sql`(not (${isImage}) and not (${isVideo}) and not (${namesPdf}))`;
}

export async function createMediaRecord(
input: Omit<MediaRecord, "id" | "_id" | "createdAt" | "updatedAt"> & {
id?: string;
Expand Down Expand Up @@ -498,6 +580,8 @@ export async function countMedia(input: {
apikey: string;
access?: "public" | "private";
group?: string;
search?: string;
kind?: MediaKindFilter;
}): Promise<number> {
const db = getDb();
const filters = [
Expand All @@ -509,6 +593,15 @@ export async function countMedia(input: {
if (input.group && input.group.trim()) {
filters.push(like(media.group, `${escapeLike(input.group.trim())}%`));
}
if (input.search?.trim()) {
filters.push(
ilike(
media.originalFileName,
`%${escapeLike(input.search.trim())}%`,
),
);
}
if (input.kind) filters.push(mediaKindCondition(input.kind));
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(media)
Expand All @@ -523,6 +616,9 @@ export async function listMedia(input: {
group?: string;
page?: number;
recordsPerPage?: number;
search?: string;
kind?: MediaKindFilter;
sort?: "newest" | "oldest" | "name" | "largest";
}): Promise<MediaRecord[]> {
const db = getDb();
const limit = input.recordsPerPage || 10;
Expand All @@ -536,11 +632,28 @@ export async function listMedia(input: {
if (input.group && input.group.trim()) {
filters.push(like(media.group, `${escapeLike(input.group.trim())}%`));
}
if (input.search?.trim()) {
filters.push(
ilike(
media.originalFileName,
`%${escapeLike(input.search.trim())}%`,
),
);
}
if (input.kind) filters.push(mediaKindCondition(input.kind));
const orderBy =
input.sort === "oldest"
? asc(media.createdAt)
: input.sort === "name"
? asc(media.originalFileName)
: input.sort === "largest"
? desc(media.size)
: desc(media.createdAt);
const rows = await db
.select()
.from(media)
.where(and(...filters))
.orderBy(desc(media.createdAt), desc(media.mediaId))
.orderBy(orderBy, desc(media.mediaId))
.limit(limit)
.offset(offset);
return rows.map(mapMedia);
Expand All @@ -560,6 +673,72 @@ export async function totalMediaSize(input: {
return Number(row?.total ?? 0);
}

export async function listApiKeyMediaSummaries(
userId: string,
apikey?: string,
): Promise<ApiKeyMediaSummary[]> {
const db = getDb();
const filters = [eq(media.userId, userId), eq(media.temp, false)];
if (apikey) filters.push(eq(media.apikey, apikey));
const countOf = (kind: MediaKindFilter) =>
sql<number>`count(*) filter (where ${mediaKindCondition(kind)})::int`;
const storageOf = (kind: MediaKindFilter) =>
sql<number>`coalesce(sum(${media.size}) filter (where ${mediaKindCondition(kind)}), 0)::float8`;
const rows = await db
.select({
apikey: media.apikey,
count: sql<number>`count(*)::int`,
storage: sql<number>`coalesce(sum(${media.size}), 0)::float8`,
images: countOf("image"),
videos: countOf("video"),
pdfs: countOf("pdf"),
others: countOf("other"),
imageStorage: storageOf("image"),
videoStorage: storageOf("video"),
pdfStorage: storageOf("pdf"),
otherStorage: storageOf("other"),
lastUpload: sql<Date | null>`max(${media.createdAt})`,
})
.from(media)
.where(and(...filters))
.groupBy(media.apikey);

return rows.map((row) => ({
...row,
count: Number(row.count),
storage: Number(row.storage),
images: Number(row.images),
videos: Number(row.videos),
pdfs: Number(row.pdfs),
others: Number(row.others),
imageStorage: Number(row.imageStorage),
videoStorage: Number(row.videoStorage),
pdfStorage: Number(row.pdfStorage),
otherStorage: Number(row.otherStorage),
lastUpload: row.lastUpload ? new Date(row.lastUpload) : null,
}));
}

export async function listMediaRecordsByIds(input: {
userId: string;
apikey: string;
mediaIds: string[];
}): Promise<MediaRecord[]> {
if (input.mediaIds.length === 0) return [];
const db = getDb();
const rows = await db
.select()
.from(media)
.where(
and(
eq(media.userId, input.userId),
eq(media.apikey, input.apikey),
inArray(media.mediaId, input.mediaIds),
),
);
return rows.map(mapMedia);
}

export async function deleteMediaRecord(
userId: string,
mediaId: string,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/media/GetPageProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ export default interface GetPageProps {
access?: AccessControl;
group?: string;
recordsPerPage?: number;
search?: string;
kind?: "image" | "video" | "pdf" | "other";
sort?: "newest" | "oldest" | "name" | "largest";
}
15 changes: 9 additions & 6 deletions apps/api/src/media/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ export async function deleteMedia(req: any, res: any) {
}
}

/* HTTP status for an error from mediaService.sealMedia. Shared by the
API-key route and the dashboard's session route. */
export function sealErrorStatus(err: { message?: string }): number {
if (err.message === "Media not found") return 404;
if (err.message === "Media is already sealed") return 409;
return 500;
}

export async function sealMedia(req: any, res: any) {
const { mediaId } = req.params;

Expand All @@ -165,12 +173,7 @@ export async function sealMedia(req: any, res: any) {

return res.status(200).json(media);
} catch (err: any) {
const statusCode =
err.message === "Media not found"
? 404
: err.message === "Media is already sealed"
? 409
: 500;
const statusCode = sealErrorStatus(err);
if (statusCode === 500) {
logger.error({ err }, err.message);
}
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/media/kind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const MEDIA_KINDS = ["image", "video", "pdf", "other"] as const;
export type MediaKind = (typeof MEDIA_KINDS)[number];

/* The one rule for what kind a file is. mediaKindCondition() in
db/repository.ts is its SQL twin; keep the two in step. */
export function mediaKind(mimeType: string): MediaKind {
const type = mimeType.toLowerCase();
if (type.startsWith("image/")) return "image";
if (type.startsWith("video/")) return "video";
if (type.includes("pdf")) return "pdf";
return "other";
}

export function parseMediaKind(value: unknown): MediaKind | undefined {
return MEDIA_KINDS.find((kind) => kind === value);
}
20 changes: 18 additions & 2 deletions apps/api/src/media/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,17 @@ export function buildMediaCountQuery({
apikey,
access,
group,
search,
kind,
}: {
userId: string;
apikey: string;
access?: AccessControl;
group?: string;
search?: string;
kind?: GetPageProps["kind"];
}) {
return { userId: String(userId), apikey, access, group };
return { userId: String(userId), apikey, access, group, search, kind };
}

function escapeRegex(value: string): string {
Expand All @@ -52,11 +56,15 @@ export async function getMediaCount({
apikey,
access,
group,
search,
kind,
}: {
userId: string;
apikey: string;
access?: AccessControl;
group?: string;
search?: string;
kind?: GetPageProps["kind"];
}): Promise<number> {
return countMedia({
userId: String(userId),
Expand All @@ -68,6 +76,8 @@ export async function getMediaCount({
? "public"
: undefined,
group,
search,
kind,
});
}

Expand All @@ -88,7 +98,10 @@ export async function getPaginatedMedia({
page,
group,
recordsPerPage,
}: GetPageProps): Promise<MediaWithUserId[]> {
search,
kind,
sort,
}: GetPageProps): Promise<Array<MediaWithUserId & { createdAt: Date }>> {
return listMedia({
userId: String(userId),
apikey,
Expand All @@ -100,6 +113,9 @@ export async function getPaginatedMedia({
group,
page,
recordsPerPage: recordsPerPage || numberOfRecordsPerPage,
search,
kind,
sort,
});
}

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/media/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface MediaListItemResponse {
thumbnail: string;
caption?: string;
group?: string;
createdAt: Date;
}

export const mediaResponseSchema = Joi.object<MediaResponse>({
Expand All @@ -58,6 +59,7 @@ export const mediaListItemResponseSchema = Joi.object<MediaListItemResponse>({
thumbnail: Joi.string().uri().optional().allow(""),
caption: Joi.string().optional().allow(""),
group: Joi.string().optional(),
createdAt: Joi.date().iso().required(),
});

export const mediaCountResponseSchema = Joi.object({
Expand Down
Loading
Loading