From 6e535e4bb7821feb6df54490213d1a224d9aa52d Mon Sep 17 00:00:00 2001 From: Alexander Elsner Date: Sun, 5 Jul 2026 21:18:51 +0200 Subject: [PATCH] feat: add opt-in folder drag-and-drop preference with GET and PUT endpoints Adds the schemaless 'folder-dnd' user preference ({ enabled: boolean }, default false) plus GET/PUT /account/preferences/folder-dnd, mirroring the existing auto-mark-seen and folder-nesting preferences. Gates the web sidebar's folder drag-and-drop behind an explicit opt-in. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/utils/preferences.ts | 13 +++++ .../v1/routes/account/preferences/index.ts | 48 ++++++++++++++++++ .../v1/routes/account/preferences/model.ts | 10 ++++ tests/api.routes.test.ts | 50 +++++++++++++++++++ 4 files changed, 121 insertions(+) diff --git a/src/api/utils/preferences.ts b/src/api/utils/preferences.ts index d720ee1..6fec527 100644 --- a/src/api/utils/preferences.ts +++ b/src/api/utils/preferences.ts @@ -22,6 +22,11 @@ export namespace UserPreferences { // instead of lifting them to top-level siblings. Default on. nestUnderInbox: z.boolean().default(true), }), + "folder-dnd": z.object({ + // Whether folders can be reorganised by drag-and-drop in the sidebar. + // Opt-in, so default off. + enabled: z.boolean().default(false), + }), } as const; export type Key = keyof typeof schemas; @@ -102,6 +107,14 @@ export class UserPreferencesHandler { await this.set(userID, "folder-nesting", data); } + static async getFolderDnd(userID: number) { + return this.get(userID, "folder-dnd"); + } + + static async setFolderDnd(userID: number, data: z.infer<(typeof UserPreferences.schemas)["folder-dnd"]>) { + await this.set(userID, "folder-dnd", data); + } + static async deleteAllForUser(userID: number): Promise { await DB.instance().delete(DB.Tables.userPreferences).where( eq(DB.Tables.userPreferences.user_id, userID) diff --git a/src/api/versions/v1/routes/account/preferences/index.ts b/src/api/versions/v1/routes/account/preferences/index.ts index bde9f94..aba3f11 100644 --- a/src/api/versions/v1/routes/account/preferences/index.ts +++ b/src/api/versions/v1/routes/account/preferences/index.ts @@ -152,3 +152,51 @@ router.put('/folder-nesting', } ); + +router.get('/folder-dnd', + + APIRouteSpec.authenticated({ + summary: "Get folder drag-and-drop preference", + description: "Retrieve whether folders can be reorganised by drag-and-drop in the sidebar for the authenticated user.", + tags: [DOCS_TAGS.ACCOUNT_PREFERENCES], + + responses: APIResponseSpec.describeBasic( + APIResponseSpec.success("Folder drag-and-drop preference retrieved successfully", AccountPreferencesModel.FolderDnd.Response), + ) + }), + + async (c) => { + const authContext = AuthHandler.AuthContext.getAsSession(c); + + const preference = await UserPreferencesHandler.getFolderDnd(authContext.user_id); + + return APIResponse.success(c, "Folder drag-and-drop preference retrieved successfully", preference); + } + +); + +router.put('/folder-dnd', + + APIRouteSpec.authenticated({ + summary: "Update folder drag-and-drop preference", + description: "Set whether folders can be reorganised by drag-and-drop in the sidebar for the authenticated user.", + tags: [DOCS_TAGS.ACCOUNT_PREFERENCES], + + responses: APIResponseSpec.describeWithWrongInputs( + APIResponseSpec.successNoData("Folder drag-and-drop preference updated successfully"), + ) + }), + + validator("json", AccountPreferencesModel.FolderDnd.Body), + + async (c) => { + const authContext = AuthHandler.AuthContext.getAsSession(c); + + const body = c.req.valid("json"); + + await UserPreferencesHandler.setFolderDnd(authContext.user_id, body); + + return APIResponse.successNoData(c, "Folder drag-and-drop preference updated successfully"); + } + +); diff --git a/src/api/versions/v1/routes/account/preferences/model.ts b/src/api/versions/v1/routes/account/preferences/model.ts index 1c0ceb2..0547b25 100644 --- a/src/api/versions/v1/routes/account/preferences/model.ts +++ b/src/api/versions/v1/routes/account/preferences/model.ts @@ -30,3 +30,13 @@ export namespace AccountPreferencesModel.FolderNesting { export type Body = z.infer; } + +export namespace AccountPreferencesModel.FolderDnd { + + export const Response = UserPreferences.schemas["folder-dnd"]; + export type Response = z.infer; + + export const Body = Response; + export type Body = z.infer; + +} diff --git a/tests/api.routes.test.ts b/tests/api.routes.test.ts index 2fe1c2b..05595b3 100644 --- a/tests/api.routes.test.ts +++ b/tests/api.routes.test.ts @@ -661,6 +661,56 @@ describe("Account Preferences Routes", async () => { await makeAPIRequest("/v1/account/preferences/folder-nesting", {}, 401); }); + test("GET /v1/account/preferences/folder-dnd defaults to enabled=false with no stored row", async () => { + + const dndUser = await seedUser("user", { username: "folderdnduser" }, "DndP@ss1"); + const dndSession = await seedSession(dndUser.id).then(s => s.token); + + const data = await makeAPIRequest("/v1/account/preferences/folder-dnd", { + authToken: dndSession, + expectedBodySchema: AccountPreferencesModel.FolderDnd.Response + }); + + expect(data.enabled).toBe(false); + + // Default is computed, not persisted. + const dbresult = DB.instance().select().from(DB.Tables.userPreferences).where( + eq(DB.Tables.userPreferences.user_id, dndUser.id) + ).all(); + expect(dbresult.length).toBe(0); + + SessionHandler.inValidateAllSessionsForUser(dndUser.id); + DB.instance().delete(DB.Tables.users).where(eq(DB.Tables.users.id, dndUser.id)).run(); + }); + + test("PUT /v1/account/preferences/folder-dnd persists enabled=true and reads it back", async () => { + + await makeAPIRequest("/v1/account/preferences/folder-dnd", { + method: "PUT", + authToken: session_token, + body: { enabled: true } + }); + + const data = await makeAPIRequest("/v1/account/preferences/folder-dnd", { + authToken: session_token, + expectedBodySchema: AccountPreferencesModel.FolderDnd.Response + }); + + expect(data.enabled).toBe(true); + + const dbresult = DB.instance().select().from(DB.Tables.userPreferences).where( + and( + eq(DB.Tables.userPreferences.user_id, preferencesTestUser.id), + eq(DB.Tables.userPreferences.key, "folder-dnd") + ) + ).all(); + expect(dbresult.length).toBe(1); + }); + + test("GET /v1/account/preferences/folder-dnd without auth fails", async () => { + await makeAPIRequest("/v1/account/preferences/folder-dnd", {}, 401); + }); + }); describe("Mail Account Routes", async () => {