즐겨찾기 기능 구현

This commit is contained in:
static
2026-01-17 19:41:52 +09:00
parent befa535526
commit 420e30f677
24 changed files with 605 additions and 14 deletions

View File

@@ -8,10 +8,14 @@ type IntegrityErrorMessages =
| "User client already exists"
// File
| "Directory not found"
| "Directory already favorited"
| "Directory not favorited"
| "File not found"
| "File is not legacy"
| "File not found in category"
| "File already added to category"
| "File already favorited"
| "File not favorited"
| "Invalid DEK version"
// HSK
| "HSK already registered"

View File

@@ -13,6 +13,7 @@ interface Directory {
encDek: string;
dekVersion: Date;
encName: Ciphertext;
isFavorite: boolean;
}
interface File {
@@ -31,6 +32,7 @@ interface File {
encName: Ciphertext;
encCreatedAt: Ciphertext | null;
encLastModifiedAt: Ciphertext;
isFavorite: boolean;
}
interface FileCategory {
@@ -42,7 +44,7 @@ interface FileCategory {
encName: Ciphertext;
}
export const registerDirectory = async (params: Omit<Directory, "id">) => {
export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
@@ -97,6 +99,7 @@ export const getAllDirectoriesByParent = async (userId: number, parentId: Direct
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
isFavorite: directory.is_favorite,
}) satisfies Directory,
);
};
@@ -130,6 +133,7 @@ export const getAllRecursiveDirectoriesByParent = async (userId: number, parentI
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
isFavorite: directory.is_favorite,
}) satisfies Directory,
);
};
@@ -151,6 +155,7 @@ export const getDirectory = async (userId: number, directoryId: number) => {
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
isFavorite: directory.is_favorite,
} satisfies Directory)
: null;
};
@@ -243,7 +248,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
});
};
export const registerFile = async (trx: typeof db, params: Omit<File, "id">) => {
export const registerFile = async (trx: typeof db, params: Omit<File, "id" | "isFavorite">) => {
if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
throw new Error("Invalid arguments");
}
@@ -305,6 +310,7 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
}) satisfies File,
);
};
@@ -357,6 +363,7 @@ export const getAllFilesByCategory = async (
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
isRecursive: file.depth > 0,
}) satisfies File & { isRecursive: boolean },
);
@@ -393,6 +400,7 @@ export const getLegacyFiles = async (userId: number, limit: number = 100) => {
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
}) satisfies File,
);
};
@@ -436,6 +444,7 @@ export const getFilesWithoutThumbnail = async (userId: number, limit: number = 1
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
}) satisfies File,
);
};
@@ -480,6 +489,7 @@ export const getFile = async (userId: number, fileId: number) => {
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
} satisfies File)
: null;
};
@@ -518,6 +528,7 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[])
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
categories: file.categories.map((category) => ({
id: category.id,
parentId: category.parent_id ?? "root",
@@ -803,3 +814,127 @@ export const removeFileFromCategory = async (fileId: number, categoryId: number)
.execute();
});
};
export const setFileFavorite = async (userId: number, fileId: number, isFavorite: boolean) => {
await db.transaction().execute(async (trx) => {
const file = await trx
.selectFrom("file")
.select("is_favorite")
.where("id", "=", fileId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
} else if (file.is_favorite === isFavorite) {
throw new IntegrityError(isFavorite ? "File already favorited" : "File not favorited");
}
await trx
.updateTable("file")
.set({ is_favorite: isFavorite })
.where("id", "=", fileId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: isFavorite ? "add-to-favorites" : "remove-from-favorites",
})
.execute();
});
};
export const setDirectoryFavorite = async (
userId: number,
directoryId: number,
isFavorite: boolean,
) => {
await db.transaction().execute(async (trx) => {
const directory = await trx
.selectFrom("directory")
.select("is_favorite")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!directory) {
throw new IntegrityError("Directory not found");
} else if (directory.is_favorite === isFavorite) {
throw new IntegrityError(
isFavorite ? "Directory already favorited" : "Directory not favorited",
);
}
await trx
.updateTable("directory")
.set({ is_favorite: isFavorite })
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(),
action: isFavorite ? "add-to-favorites" : "remove-from-favorites",
})
.execute();
});
};
export const getAllFavoriteFiles = async (userId: number) => {
const files = await db
.selectFrom("file")
.selectAll()
.where("user_id", "=", userId)
.where("is_favorite", "=", true)
.execute();
return files.map(
(file) =>
({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
}) satisfies File,
);
};
export const getAllFavoriteDirectories = async (userId: number) => {
const directories = await db
.selectFrom("directory")
.selectAll()
.where("user_id", "=", userId)
.where("is_favorite", "=", true)
.execute();
return directories.map(
(directory) =>
({
id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
isFavorite: directory.is_favorite,
}) satisfies Directory,
);
};

View File

@@ -0,0 +1,29 @@
import { Kysely, sql } from "kysely";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const up = async (db: Kysely<any>) => {
// file.ts
await db.schema
.alterTable("directory")
.addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false))
.execute();
await db.schema
.alterTable("file")
.addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false))
.execute();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const down = async (db: Kysely<any>) => {
await db
.deleteFrom("file_log")
.where("action", "in", ["add-to-favorites", "remove-from-favorites"])
.execute();
await db
.deleteFrom("directory_log")
.where("action", "in", ["add-to-favorites", "remove-from-favorites"])
.execute();
await db.schema.alterTable("file").dropColumn("is_favorite").execute();
await db.schema.alterTable("directory").dropColumn("is_favorite").execute();
};

View File

@@ -2,10 +2,12 @@ import * as Initial1737357000 from "./1737357000-Initial";
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail";
import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload";
import * as AddFavorites1768643000 from "./1768643000-AddFavorites";
export default {
"1737357000-Initial": Initial1737357000,
"1737422340-AddFileCategory": AddFileCategory1737422340,
"1738409340-AddThumbnail": AddThumbnail1738409340,
"1768062380-AddChunkedUpload": AddChunkedUpload1768062380,
"1768643000-AddFavorites": AddFavorites1768643000,
};

View File

@@ -9,13 +9,14 @@ interface DirectoryTable {
encrypted_data_encryption_key: string; // Base64
data_encryption_key_version: Date;
encrypted_name: Ciphertext;
is_favorite: Generated<boolean>;
}
interface DirectoryLogTable {
id: Generated<number>;
directory_id: number;
timestamp: ColumnType<Date, Date, never>;
action: "create" | "rename";
action: "create" | "rename" | "add-to-favorites" | "remove-from-favorites";
new_name: Ciphertext | null;
}
@@ -35,13 +36,21 @@ interface FileTable {
encrypted_name: Ciphertext;
encrypted_created_at: Ciphertext | null;
encrypted_last_modified_at: Ciphertext;
is_favorite: Generated<boolean>;
}
interface FileLogTable {
id: Generated<number>;
file_id: number;
timestamp: ColumnType<Date, Date, never>;
action: "create" | "rename" | "migrate" | "add-to-category" | "remove-from-category";
action:
| "create"
| "rename"
| "migrate"
| "add-to-category"
| "remove-from-category"
| "add-to-favorites"
| "remove-from-favorites";
new_name: Ciphertext | null;
category_id: number | null;
}