fix(housekeeping): harden preference persistence
This commit is contained in:
1 parent
64bb230de5
commit
4e0bf598ed
7 files changed
+272
-82
No files matched your search
@@ -1,89 +1,90 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||||
getHousekeepingCapabilityContext: vi.fn(),
|
getHousekeepingCapabilityContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
|
const preferenceRepository = vi.hoisted(() => ({
|
||||||
|
read: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/housekeeping-preferences-repository", () => ({
|
||||||
|
housekeepingPreferencesRepository: preferenceRepository,
|
||||||
|
}));
|
||||||
|
|
||||||
import { defaultHousekeepingPreferences } from "@/features/housekeeping/foundation/preferences/schema";
|
import { defaultHousekeepingPreferences } from "@/features/housekeeping/foundation/preferences/schema";
|
||||||
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
import {
|
import {
|
||||||
type HousekeepingPreferencesActionDependencies,
|
|
||||||
loadHousekeepingPreferences,
|
loadHousekeepingPreferences,
|
||||||
saveHousekeepingPreferences,
|
saveHousekeepingPreferences,
|
||||||
} from "./housekeeping-preferences";
|
} from "./housekeeping-preferences";
|
||||||
|
|
||||||
const registry: HousekeepingRegistry = { domains: [] };
|
|
||||||
const allowedContext = {
|
const allowedContext = {
|
||||||
actor: { id: 42, username: "operator", rank: 0 },
|
actor: { id: 42, username: "operator", rank: 0 },
|
||||||
isSuperAdmin: false,
|
isSuperAdmin: false,
|
||||||
has: (slug: string) => slug === "admin.dashboard",
|
has: () => true,
|
||||||
hasAny: (...slugs: string[]) => slugs.includes("admin.dashboard"),
|
hasAny: () => true,
|
||||||
hasAll: (...slugs: string[]) =>
|
hasAll: () => true,
|
||||||
slugs.every((slug) => slug === "admin.dashboard"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function dependencies(
|
beforeEach(() => {
|
||||||
context = allowedContext,
|
vi.clearAllMocks();
|
||||||
): HousekeepingPreferencesActionDependencies & {
|
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValue(allowedContext);
|
||||||
repository: HousekeepingPreferencesRepository;
|
preferenceRepository.read.mockResolvedValue(defaultHousekeepingPreferences());
|
||||||
} {
|
});
|
||||||
return {
|
|
||||||
repository: {
|
|
||||||
read: vi.fn().mockResolvedValue(defaultHousekeepingPreferences()),
|
|
||||||
upsert: vi.fn(),
|
|
||||||
},
|
|
||||||
registry,
|
|
||||||
getContext: vi.fn().mockResolvedValue(context),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("housekeeping preference actions", () => {
|
describe("housekeeping preference actions", () => {
|
||||||
it("derives the read owner from request capability context", async () => {
|
it("does not expose dependency or user identity parameters to callers", () => {
|
||||||
const deps = dependencies();
|
expect(loadHousekeepingPreferences).toHaveLength(0);
|
||||||
|
expect(saveHousekeepingPreferences).toHaveLength(1);
|
||||||
const result = await loadHousekeepingPreferences(deps);
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
expect(deps.repository.read).toHaveBeenCalledWith(42);
|
|
||||||
expect(deps.getContext).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a denied operator without reading or writing another user preferences", async () => {
|
it("derives the read owner from the server capability context", async () => {
|
||||||
const deps = dependencies({ ...allowedContext, hasAny: () => false });
|
const result = await loadHousekeepingPreferences();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(preferenceRepository.read).toHaveBeenCalledWith(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a denied operator without reading or writing preferences", async () => {
|
||||||
|
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
|
||||||
|
...allowedContext,
|
||||||
|
hasAny: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await saveHousekeepingPreferences(
|
const result = await saveHousekeepingPreferences(
|
||||||
defaultHousekeepingPreferences(),
|
defaultHousekeepingPreferences(),
|
||||||
deps,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||||
expect(deps.repository.read).not.toHaveBeenCalled();
|
expect(preferenceRepository.read).not.toHaveBeenCalled();
|
||||||
expect(deps.repository.upsert).not.toHaveBeenCalled();
|
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates writes and persists them for the context actor only", async () => {
|
it("reconciles input before persisting or returning it", async () => {
|
||||||
const deps = dependencies();
|
|
||||||
const value = {
|
const value = {
|
||||||
...defaultHousekeepingPreferences(),
|
...defaultHousekeepingPreferences(),
|
||||||
pinnedRouteIds: ["people.users"],
|
pinnedRouteIds: ["removed.route"],
|
||||||
|
pinnedCommandIds: ["removed.command"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await saveHousekeepingPreferences(value, deps);
|
const result = await saveHousekeepingPreferences(value);
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: true, data: value });
|
expect(result).toMatchObject({
|
||||||
expect(deps.repository.upsert).toHaveBeenCalledWith(42, value);
|
ok: true,
|
||||||
|
data: { pinnedRouteIds: [], pinnedCommandIds: [] },
|
||||||
|
});
|
||||||
|
expect(preferenceRepository.upsert).toHaveBeenCalledWith(
|
||||||
|
42,
|
||||||
|
expect.objectContaining({ pinnedRouteIds: [], pinnedCommandIds: [] }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a validation result before an invalid payload reaches persistence", async () => {
|
it("returns a validation result before an invalid payload reaches persistence", async () => {
|
||||||
const deps = dependencies();
|
const result = await saveHousekeepingPreferences({ schemaVersion: 2 });
|
||||||
|
|
||||||
const result = await saveHousekeepingPreferences(
|
|
||||||
{ schemaVersion: 2 },
|
|
||||||
deps,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
|
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
|
||||||
expect(deps.repository.upsert).not.toHaveBeenCalled();
|
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,42 +1,41 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
|
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
|
||||||
import {
|
import {
|
||||||
anyCapability,
|
anyCapability,
|
||||||
fail,
|
fail,
|
||||||
type HousekeepingCapabilityContext,
|
|
||||||
type HousekeepingResult,
|
type HousekeepingResult,
|
||||||
ok,
|
ok,
|
||||||
} from "@/features/housekeeping/foundation/contracts";
|
} from "@/features/housekeeping/foundation/contracts";
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||||
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
|
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
|
||||||
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
|
|
||||||
import {
|
import {
|
||||||
type HousekeepingPreferences,
|
type HousekeepingPreferences,
|
||||||
housekeepingPreferencesSchema,
|
housekeepingPreferencesSchema,
|
||||||
} from "@/features/housekeeping/foundation/preferences/schema";
|
} from "@/features/housekeeping/foundation/preferences/schema";
|
||||||
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||||
|
import { housekeepingPreferencesRepository } from "@/lib/housekeeping-preferences-repository";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
|
||||||
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
|
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
|
||||||
|
const housekeepingRegistry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
|
|
||||||
export interface HousekeepingPreferencesActionDependencies {
|
export async function loadHousekeepingPreferences(): Promise<
|
||||||
repository: HousekeepingPreferencesRepository;
|
HousekeepingResult<HousekeepingPreferences>
|
||||||
registry: HousekeepingRegistry;
|
> {
|
||||||
getContext?: () => Promise<HousekeepingCapabilityContext>;
|
const context = await getHousekeepingCapabilityContext();
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadHousekeepingPreferences(
|
|
||||||
dependencies: HousekeepingPreferencesActionDependencies,
|
|
||||||
): Promise<HousekeepingResult<HousekeepingPreferences>> {
|
|
||||||
const context = await resolveContext(dependencies);
|
|
||||||
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
||||||
if (!authorization.ok) return authorization;
|
if (!authorization.ok) return authorization;
|
||||||
|
|
||||||
const correlationId = createCorrelationId();
|
const correlationId = createCorrelationId();
|
||||||
try {
|
try {
|
||||||
const stored = await dependencies.repository.read(context.actor.id);
|
const stored = await housekeepingPreferencesRepository.read(
|
||||||
|
context.actor.id,
|
||||||
|
);
|
||||||
return ok(
|
return ok(
|
||||||
reconcilePreferences(stored, dependencies.registry, context),
|
reconcilePreferences(stored, housekeepingRegistry, context),
|
||||||
correlationId,
|
correlationId,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -50,9 +49,8 @@ export async function loadHousekeepingPreferences(
|
|||||||
|
|
||||||
export async function saveHousekeepingPreferences(
|
export async function saveHousekeepingPreferences(
|
||||||
input: unknown,
|
input: unknown,
|
||||||
dependencies: HousekeepingPreferencesActionDependencies,
|
|
||||||
): Promise<HousekeepingResult<HousekeepingPreferences>> {
|
): Promise<HousekeepingResult<HousekeepingPreferences>> {
|
||||||
const context = await resolveContext(dependencies);
|
const context = await getHousekeepingCapabilityContext();
|
||||||
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
||||||
if (!authorization.ok) return authorization;
|
if (!authorization.ok) return authorization;
|
||||||
|
|
||||||
@@ -66,9 +64,17 @@ export async function saveHousekeepingPreferences(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reconciled = reconcilePreferences(
|
||||||
|
parsed.data,
|
||||||
|
housekeepingRegistry,
|
||||||
|
context,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await dependencies.repository.upsert(context.actor.id, parsed.data);
|
await housekeepingPreferencesRepository.upsert(
|
||||||
return ok(parsed.data, correlationId);
|
context.actor.id,
|
||||||
|
reconciled,
|
||||||
|
);
|
||||||
|
return ok(reconciled, correlationId);
|
||||||
} catch {
|
} catch {
|
||||||
return fail(
|
return fail(
|
||||||
"INTERNAL",
|
"INTERNAL",
|
||||||
@@ -77,11 +83,3 @@ export async function saveHousekeepingPreferences(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveContext(
|
|
||||||
dependencies: HousekeepingPreferencesActionDependencies,
|
|
||||||
): Promise<HousekeepingCapabilityContext> {
|
|
||||||
return dependencies.getContext
|
|
||||||
? dependencies.getContext()
|
|
||||||
: getHousekeepingCapabilityContext();
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const drizzleMocks = vi.hoisted(() => ({
|
||||||
|
eq: vi.fn((column: unknown, value: unknown) => ({ column, value })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("drizzle-orm", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("drizzle-orm")>()),
|
||||||
|
eq: drizzleMocks.eq,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HousekeepingUserPreferences } from "@/lib/db";
|
||||||
|
import { createDrizzleHousekeepingPreferencesStorage } from "@/lib/housekeeping-preferences-repository";
|
||||||
import {
|
import {
|
||||||
createHousekeepingPreferencesRepository,
|
createHousekeepingPreferencesRepository,
|
||||||
type HousekeepingPreferencesStorage,
|
type HousekeepingPreferencesStorage,
|
||||||
@@ -39,4 +51,90 @@ describe("housekeeping preferences repository", () => {
|
|||||||
payload: JSON.stringify(value),
|
payload: JSON.stringify(value),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a fresh default for malformed or incompatible stored values", async () => {
|
||||||
|
const storage: HousekeepingPreferencesStorage = {
|
||||||
|
findByUserId: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ schemaVersion: 1, payload: "{broken" })
|
||||||
|
.mockResolvedValueOnce({ schemaVersion: 2, payload: "{}" })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
schemaVersion: 1,
|
||||||
|
payload: JSON.stringify({ schemaVersion: 1 }),
|
||||||
|
}),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
};
|
||||||
|
const repository = createHousekeepingPreferencesRepository(storage);
|
||||||
|
|
||||||
|
await expect(repository.read(42)).resolves.toEqual(
|
||||||
|
defaultHousekeepingPreferences(),
|
||||||
|
);
|
||||||
|
await expect(repository.read(42)).resolves.toEqual(
|
||||||
|
defaultHousekeepingPreferences(),
|
||||||
|
);
|
||||||
|
await expect(repository.read(42)).resolves.toEqual(
|
||||||
|
defaultHousekeepingPreferences(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves genuine storage failures instead of treating them as corrupt preferences", async () => {
|
||||||
|
const storage: HousekeepingPreferencesStorage = {
|
||||||
|
findByUserId: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error("database unavailable")),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createHousekeepingPreferencesRepository(storage).read(42),
|
||||||
|
).rejects.toThrow("database unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the preferences table and user ID for the typed Drizzle read and upsert", async () => {
|
||||||
|
const limit = vi.fn().mockResolvedValue([]);
|
||||||
|
const where = vi.fn().mockReturnValue({ limit });
|
||||||
|
const from = vi.fn().mockReturnValue({ where });
|
||||||
|
const select = vi.fn().mockReturnValue({ from });
|
||||||
|
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const values = vi.fn().mockReturnValue({ onDuplicateKeyUpdate });
|
||||||
|
const insert = vi.fn().mockReturnValue({ values });
|
||||||
|
const storage = createDrizzleHousekeepingPreferencesStorage({
|
||||||
|
select,
|
||||||
|
insert,
|
||||||
|
} as never);
|
||||||
|
const value = defaultHousekeepingPreferences();
|
||||||
|
|
||||||
|
await storage.findByUserId(42);
|
||||||
|
await storage.upsert({
|
||||||
|
userId: 42,
|
||||||
|
schemaVersion: 1,
|
||||||
|
payload: JSON.stringify(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(select).toHaveBeenCalledWith({
|
||||||
|
schemaVersion: HousekeepingUserPreferences.schemaVersion,
|
||||||
|
payload: HousekeepingUserPreferences.payload,
|
||||||
|
});
|
||||||
|
expect(from).toHaveBeenCalledWith(HousekeepingUserPreferences);
|
||||||
|
expect(drizzleMocks.eq).toHaveBeenCalledWith(
|
||||||
|
HousekeepingUserPreferences.userId,
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
expect(where).toHaveBeenCalledWith({
|
||||||
|
column: HousekeepingUserPreferences.userId,
|
||||||
|
value: 42,
|
||||||
|
});
|
||||||
|
expect(values).toHaveBeenCalledWith({
|
||||||
|
userId: 42,
|
||||||
|
schemaVersion: 1,
|
||||||
|
payload: JSON.stringify(value),
|
||||||
|
});
|
||||||
|
expect(onDuplicateKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
set: expect.objectContaining({
|
||||||
|
schemaVersion: 1,
|
||||||
|
payload: JSON.stringify(value),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -32,11 +32,15 @@ export function createHousekeepingPreferencesRepository(
|
|||||||
async read(userId) {
|
async read(userId) {
|
||||||
const row = await storage.findByUserId(userId);
|
const row = await storage.findByUserId(userId);
|
||||||
if (!row) return defaultHousekeepingPreferences();
|
if (!row) return defaultHousekeepingPreferences();
|
||||||
if (row.schemaVersion !== 1) {
|
|
||||||
throw new Error("unsupported housekeeping preferences schema version");
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseHousekeepingPreferences(row.payload);
|
try {
|
||||||
|
if (row.schemaVersion !== 1) {
|
||||||
|
return defaultHousekeepingPreferences();
|
||||||
|
}
|
||||||
|
return parseHousekeepingPreferences(row.payload);
|
||||||
|
} catch {
|
||||||
|
return defaultHousekeepingPreferences();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async upsert(userId, value) {
|
async upsert(userId, value) {
|
||||||
await storage.upsert({
|
await storage.upsert({
|
||||||
|
|||||||
@@ -43,4 +43,32 @@ describe("housekeeping preferences schema", () => {
|
|||||||
|
|
||||||
expect(parseHousekeepingPreferences(JSON.stringify(value))).toEqual(value);
|
expect(parseHousekeepingPreferences(JSON.stringify(value))).toEqual(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects identifiers and lists that exceed the bounded presentation payload", () => {
|
||||||
|
const value = defaultHousekeepingPreferences();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
housekeepingPreferencesSchema.safeParse({
|
||||||
|
...value,
|
||||||
|
pinnedRouteIds: ["a".repeat(129)],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
housekeepingPreferencesSchema.safeParse({
|
||||||
|
...value,
|
||||||
|
pinnedRouteIds: Array.from(
|
||||||
|
{ length: 65 },
|
||||||
|
(_, index) => `route.${index}`,
|
||||||
|
),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
housekeepingPreferencesSchema.safeParse({
|
||||||
|
...value,
|
||||||
|
pinnedRouteIds: Array.from({ length: 33 }, (_, index) =>
|
||||||
|
`${index}`.padEnd(128, "a"),
|
||||||
|
),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const HOUSEKEEPING_PREFERENCE_MAX_IDENTIFIER_LENGTH = 128;
|
||||||
|
export const HOUSEKEEPING_PREFERENCE_MAX_ITEMS_PER_ARRAY = 64;
|
||||||
|
export const HOUSEKEEPING_PREFERENCE_MAX_SERIALIZED_BYTES = 4096;
|
||||||
|
|
||||||
export interface HousekeepingPreferences {
|
export interface HousekeepingPreferences {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
pinnedRouteIds: string[];
|
pinnedRouteIds: string[];
|
||||||
@@ -10,7 +14,10 @@ export interface HousekeepingPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uniqueIdentifiers = z
|
const uniqueIdentifiers = z
|
||||||
.array(z.string().trim().min(1))
|
.array(
|
||||||
|
z.string().trim().min(1).max(HOUSEKEEPING_PREFERENCE_MAX_IDENTIFIER_LENGTH),
|
||||||
|
)
|
||||||
|
.max(HOUSEKEEPING_PREFERENCE_MAX_ITEMS_PER_ARRAY)
|
||||||
.superRefine((values, context) => {
|
.superRefine((values, context) => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const [index, value] of values.entries()) {
|
for (const [index, value] of values.entries()) {
|
||||||
@@ -35,7 +42,18 @@ export const housekeepingPreferencesSchema: z.ZodType<HousekeepingPreferences> =
|
|||||||
widgetOrder: uniqueIdentifiers,
|
widgetOrder: uniqueIdentifiers,
|
||||||
enabledOptionalWidgetIds: uniqueIdentifiers,
|
enabledOptionalWidgetIds: uniqueIdentifiers,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict()
|
||||||
|
.superRefine((preferences, context) => {
|
||||||
|
if (
|
||||||
|
JSON.stringify(preferences).length >
|
||||||
|
HOUSEKEEPING_PREFERENCE_MAX_SERIALIZED_BYTES
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "housekeeping preferences payload is too large",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export function defaultHousekeepingPreferences(): HousekeepingPreferences {
|
export function defaultHousekeepingPreferences(): HousekeepingPreferences {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
createHousekeepingPreferencesRepository,
|
||||||
|
type HousekeepingPreferencesStorage,
|
||||||
|
} from "@/features/housekeeping/foundation/preferences/repository";
|
||||||
|
import { type Db, db, HousekeepingUserPreferences } from "@/lib/db";
|
||||||
|
|
||||||
|
type HousekeepingPreferencesDb = Pick<Db, "select" | "insert">;
|
||||||
|
|
||||||
|
export function createDrizzleHousekeepingPreferencesStorage(
|
||||||
|
database: HousekeepingPreferencesDb,
|
||||||
|
): HousekeepingPreferencesStorage {
|
||||||
|
return {
|
||||||
|
async findByUserId(userId) {
|
||||||
|
const rows = await database
|
||||||
|
.select({
|
||||||
|
schemaVersion: HousekeepingUserPreferences.schemaVersion,
|
||||||
|
payload: HousekeepingUserPreferences.payload,
|
||||||
|
})
|
||||||
|
.from(HousekeepingUserPreferences)
|
||||||
|
.where(eq(HousekeepingUserPreferences.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
async upsert(row) {
|
||||||
|
await database
|
||||||
|
.insert(HousekeepingUserPreferences)
|
||||||
|
.values(row)
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
schemaVersion: row.schemaVersion,
|
||||||
|
payload: row.payload,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const housekeepingPreferencesRepository =
|
||||||
|
createHousekeepingPreferencesRepository(
|
||||||
|
createDrizzleHousekeepingPreferencesStorage(db),
|
||||||
|
);
|
||||||
Reference in new issue
Block a user