Add schedule validation and service layer
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findOverlap,
|
||||
formatTime,
|
||||
snapMinutes,
|
||||
toHm,
|
||||
validateBlock,
|
||||
type BlockInput,
|
||||
type BlockSibling
|
||||
} from './schedule-utils';
|
||||
|
||||
const input = (overrides: Partial<BlockInput> = {}): BlockInput => ({
|
||||
startMinutes: 6 * 60,
|
||||
endMinutes: 8 * 60,
|
||||
proclamationIds: ['p1'],
|
||||
...overrides
|
||||
});
|
||||
|
||||
const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({
|
||||
id,
|
||||
startMinutes,
|
||||
endMinutes
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats 12-hour clock labels', () => {
|
||||
expect(formatTime(0)).toBe('12:00 AM');
|
||||
expect(formatTime(6 * 60)).toBe('6:00 AM');
|
||||
expect(formatTime(8 * 60 + 5)).toBe('8:05 AM');
|
||||
expect(formatTime(12 * 60)).toBe('12:00 PM');
|
||||
expect(formatTime(13 * 60 + 30)).toBe('1:30 PM');
|
||||
expect(formatTime(23 * 60 + 59)).toBe('11:59 PM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toHm', () => {
|
||||
it('formats 24-hour HH:MM strings', () => {
|
||||
expect(toHm(0)).toBe('00:00');
|
||||
expect(toHm(6 * 60)).toBe('06:00');
|
||||
expect(toHm(23 * 60 + 59)).toBe('23:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapMinutes', () => {
|
||||
it('rounds to the nearest 15 minutes and clamps to the day', () => {
|
||||
expect(snapMinutes(0)).toBe(0);
|
||||
expect(snapMinutes(7)).toBe(0);
|
||||
expect(snapMinutes(8)).toBe(15);
|
||||
expect(snapMinutes(370)).toBe(375);
|
||||
expect(snapMinutes(-10)).toBe(0);
|
||||
expect(snapMinutes(2000)).toBe(1440);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOverlap', () => {
|
||||
it('returns null for adjacent ranges', () => {
|
||||
expect(
|
||||
findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the sibling for a partial overlap', () => {
|
||||
const other = sibling(7 * 60, 9 * 60);
|
||||
expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBlock', () => {
|
||||
it('accepts a valid block with no siblings', () => {
|
||||
expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects equal start and end times', () => {
|
||||
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "Start time must be before end time. Overnight blocks aren't supported."
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an end time past midnight (overnight blocks disallowed)', () => {
|
||||
const result = validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), []);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a negative start time', () => {
|
||||
expect(validateBlock(input({ startMinutes: -1 }), []).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('requires at least one proclamation', () => {
|
||||
expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({
|
||||
ok: false,
|
||||
error: 'Select at least one proclamation.'
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces the four-block maximum on create', () => {
|
||||
const siblings = [
|
||||
sibling(0, 60, 'a'),
|
||||
sibling(60, 120, 'b'),
|
||||
sibling(120, 180, 'c'),
|
||||
sibling(180, 240, 'd')
|
||||
];
|
||||
const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, {
|
||||
isCreate: true
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
|
||||
});
|
||||
|
||||
it('allows adjacent blocks (end exactly equals next start)', () => {
|
||||
expect(
|
||||
validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)])
|
||||
).toEqual({
|
||||
ok: true
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an exact duplicate range and names the conflicting block', () => {
|
||||
const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'This overlaps with your 6:00 AM–8:00 AM block.'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a partial overlap', () => {
|
||||
const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [
|
||||
sibling(8 * 60, 10 * 60)
|
||||
]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a block that contains another block', () => {
|
||||
const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [
|
||||
sibling(6 * 60, 8 * 60)
|
||||
]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('does not apply the maximum when updating (isCreate false)', () => {
|
||||
const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')];
|
||||
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// Isomorphic, dependency-free schedule helpers. Used by the server service
|
||||
// layer, the v2 serializers and the client-side Block modal (for live
|
||||
// pre-validation), so it must not import Prisma or any server-only code.
|
||||
|
||||
export const MAX_BLOCKS = 4;
|
||||
export const MINUTES_PER_DAY = 24 * 60;
|
||||
export const SNAP_MINUTES = 15;
|
||||
export const MINUTE_STEP = 5;
|
||||
|
||||
export type BlockSibling = {
|
||||
id?: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
};
|
||||
|
||||
export type BlockInput = {
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
proclamationIds: string[];
|
||||
};
|
||||
|
||||
export type ValidationResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
function normalizeMinutes(minutes: number): number {
|
||||
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
|
||||
}
|
||||
|
||||
/** Format minutes-from-midnight as a 12-hour clock label, e.g. `6:00 AM`. */
|
||||
export function formatTime(minutes: number): string {
|
||||
const normalized = normalizeMinutes(minutes);
|
||||
const hours24 = Math.floor(normalized / 60);
|
||||
const mins = normalized % 60;
|
||||
const period = hours24 < 12 ? 'AM' : 'PM';
|
||||
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||||
return `${hours12}:${String(mins).padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
/** Format minutes-from-midnight as a 24-hour `HH:MM` string. */
|
||||
export function toHm(minutes: number): string {
|
||||
const normalized = normalizeMinutes(minutes);
|
||||
const hours = Math.floor(normalized / 60);
|
||||
const mins = normalized % 60;
|
||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
|
||||
export function snapMinutes(minutes: number): number {
|
||||
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
|
||||
return Math.max(0, Math.min(MINUTES_PER_DAY, snapped));
|
||||
}
|
||||
|
||||
/** First sibling whose half-open range [start, end) intersects the input. */
|
||||
export function findOverlap(
|
||||
input: { startMinutes: number; endMinutes: number },
|
||||
siblings: BlockSibling[]
|
||||
): BlockSibling | null {
|
||||
for (const sibling of siblings) {
|
||||
if (input.startMinutes < sibling.endMinutes && sibling.startMinutes < input.endMinutes) {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Block against the schedule's other Blocks. `siblings` must exclude
|
||||
* the Block being edited. `isCreate` enables the 4-block maximum (an update
|
||||
* never increases the count).
|
||||
*/
|
||||
export function validateBlock(
|
||||
input: BlockInput,
|
||||
siblings: BlockSibling[],
|
||||
opts: { isCreate?: boolean } = {}
|
||||
): ValidationResult {
|
||||
const { startMinutes, endMinutes, proclamationIds } = input;
|
||||
|
||||
if (
|
||||
!Number.isInteger(startMinutes) ||
|
||||
!Number.isInteger(endMinutes) ||
|
||||
startMinutes < 0 ||
|
||||
endMinutes > MINUTES_PER_DAY ||
|
||||
startMinutes >= endMinutes
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Start time must be before end time. Overnight blocks aren't supported."
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
|
||||
return { ok: false, error: 'Select at least one proclamation.' };
|
||||
}
|
||||
|
||||
if (opts.isCreate && siblings.length >= MAX_BLOCKS) {
|
||||
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks.` };
|
||||
}
|
||||
|
||||
const overlap = findOverlap(input, siblings);
|
||||
if (overlap) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `This overlaps with your ${formatTime(overlap.startMinutes)}–${formatTime(
|
||||
overlap.endMinutes
|
||||
)} block.`
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
schedule: { findUnique: vi.fn(), create: vi.fn() },
|
||||
block: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
|
||||
|
||||
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
|
||||
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
|
||||
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
|
||||
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
|
||||
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
|
||||
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
|
||||
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
|
||||
|
||||
const validInput = {
|
||||
startMinutes: 6 * 60,
|
||||
endMinutes: 8 * 60,
|
||||
proclamationIds: ['p1']
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOrCreateSchedule', () => {
|
||||
it('returns the existing schedule', async () => {
|
||||
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
|
||||
|
||||
const result = await getOrCreateSchedule('u1');
|
||||
|
||||
expect(result).toEqual({ id: 's1', userId: 'u1' });
|
||||
expect(scheduleCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the singleton schedule when missing', async () => {
|
||||
scheduleFindUnique.mockResolvedValue(null);
|
||||
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
|
||||
|
||||
await getOrCreateSchedule('u1');
|
||||
|
||||
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listBlocks', () => {
|
||||
it('scopes to the user and includes proclamations with frequencies', async () => {
|
||||
blockFindMany.mockResolvedValue([]);
|
||||
|
||||
await listBlocks('u1');
|
||||
|
||||
expect(blockFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { schedule: { userId: 'u1' } },
|
||||
orderBy: { startMinutes: 'asc' },
|
||||
include: expect.objectContaining({ proclamations: expect.anything() })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBlock', () => {
|
||||
beforeEach(() => {
|
||||
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
|
||||
});
|
||||
|
||||
it('creates a block with deduped proclamations in order', async () => {
|
||||
blockFindMany.mockResolvedValue([]);
|
||||
blockCreate.mockResolvedValue({ id: 'new-block' });
|
||||
|
||||
const result = await createBlock('u1', {
|
||||
...validInput,
|
||||
proclamationIds: ['p1', 'p1', 'p2']
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, id: 'new-block' });
|
||||
expect(blockCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
scheduleId: 's1',
|
||||
startMinutes: validInput.startMinutes,
|
||||
endMinutes: validInput.endMinutes,
|
||||
proclamations: {
|
||||
create: [
|
||||
{ proclamationId: 'p1', sortOrder: 0 },
|
||||
{ proclamationId: 'p2', sortOrder: 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a fifth block without creating', async () => {
|
||||
blockFindMany.mockResolvedValue([
|
||||
{ id: 'a', startMinutes: 0, endMinutes: 60 },
|
||||
{ id: 'b', startMinutes: 60, endMinutes: 120 },
|
||||
{ id: 'c', startMinutes: 120, endMinutes: 180 },
|
||||
{ id: 'd', startMinutes: 180, endMinutes: 240 }
|
||||
]);
|
||||
|
||||
const result = await createBlock('u1', validInput);
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
|
||||
expect(blockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an overlapping block with a specific message', async () => {
|
||||
blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
|
||||
|
||||
const result = await createBlock('u1', validInput);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'This overlaps with your 7:00 AM–9:00 AM block.'
|
||||
});
|
||||
expect(blockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBlock', () => {
|
||||
it('returns not found when the block does not belong to the user', async () => {
|
||||
blockFindFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await updateBlock('u1', 'missing', validInput);
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'Block not found.' });
|
||||
expect(blockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces proclamations and excludes itself from overlap checks', async () => {
|
||||
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
|
||||
blockFindMany.mockResolvedValue([]);
|
||||
blockUpdate.mockResolvedValue({ id: 'b1' });
|
||||
|
||||
const result = await updateBlock('u1', 'b1', validInput);
|
||||
|
||||
expect(result).toEqual({ ok: true, id: 'b1' });
|
||||
expect(blockFindMany).toHaveBeenCalledWith({
|
||||
where: { scheduleId: 's1', id: { not: 'b1' } },
|
||||
select: { id: true, startMinutes: true, endMinutes: true }
|
||||
});
|
||||
expect(blockUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'b1' },
|
||||
data: {
|
||||
startMinutes: validInput.startMinutes,
|
||||
endMinutes: validInput.endMinutes,
|
||||
proclamations: {
|
||||
deleteMany: {},
|
||||
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBlock', () => {
|
||||
it('returns not found when the block does not belong to the user', async () => {
|
||||
blockFindFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await deleteBlock('u1', 'missing');
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'Block not found.' });
|
||||
expect(blockDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an owned block', async () => {
|
||||
blockFindFirst.mockResolvedValue({ id: 'b1' });
|
||||
blockDelete.mockResolvedValue({ id: 'b1' });
|
||||
|
||||
const result = await deleteBlock('u1', 'b1');
|
||||
|
||||
expect(result).toEqual({ ok: true, id: 'b1' });
|
||||
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Schedule service layer. Owns the database orchestration and enforcement of
|
||||
// the Block business rules (at most 4 per user, at least one Proclamation,
|
||||
// no overnight ranges, no overlaps). Pure validation lives in
|
||||
// `$lib/schedule-utils` so the client modal can reuse it.
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { validateBlock, type BlockInput } from '$lib/schedule-utils';
|
||||
|
||||
export {
|
||||
MAX_BLOCKS,
|
||||
MINUTES_PER_DAY,
|
||||
SNAP_MINUTES,
|
||||
MINUTE_STEP,
|
||||
formatTime,
|
||||
toHm,
|
||||
snapMinutes,
|
||||
findOverlap,
|
||||
validateBlock
|
||||
} from '$lib/schedule-utils';
|
||||
export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils';
|
||||
|
||||
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
|
||||
|
||||
function uniqueIds(ids: string[]): string[] {
|
||||
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
|
||||
}
|
||||
|
||||
/** Ensure the user has a Schedule row, creating the singleton on first use. */
|
||||
export async function getOrCreateSchedule(userId: string) {
|
||||
const existing = await prisma.schedule.findUnique({ where: { userId } });
|
||||
if (existing) return existing;
|
||||
return prisma.schedule.create({ data: { userId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's Blocks with their selected Proclamations (and frequencies for
|
||||
* canonical-id mapping), ordered by start time.
|
||||
*/
|
||||
export async function listBlocks(userId: string) {
|
||||
return prisma.block.findMany({
|
||||
where: { schedule: { userId } },
|
||||
orderBy: { startMinutes: 'asc' },
|
||||
include: {
|
||||
proclamations: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
proclamation: { include: { solfeggioFrequency: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createBlock(userId: string, input: BlockInput): Promise<BlockMutationResult> {
|
||||
const schedule = await getOrCreateSchedule(userId);
|
||||
const siblings = await prisma.block.findMany({
|
||||
where: { scheduleId: schedule.id },
|
||||
select: { id: true, startMinutes: true, endMinutes: true }
|
||||
});
|
||||
|
||||
const validation = validateBlock(input, siblings, { isCreate: true });
|
||||
if (!validation.ok) return validation;
|
||||
|
||||
const proclamationIds = uniqueIds(input.proclamationIds);
|
||||
const block = await prisma.block.create({
|
||||
data: {
|
||||
scheduleId: schedule.id,
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
proclamations: {
|
||||
create: proclamationIds.map((proclamationId, index) => ({
|
||||
proclamationId,
|
||||
sortOrder: index
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, id: block.id };
|
||||
}
|
||||
|
||||
export async function updateBlock(
|
||||
userId: string,
|
||||
blockId: string,
|
||||
input: BlockInput
|
||||
): Promise<BlockMutationResult> {
|
||||
const block = await prisma.block.findFirst({
|
||||
where: { id: blockId, schedule: { userId } },
|
||||
select: { id: true, scheduleId: true }
|
||||
});
|
||||
if (!block) return { ok: false, error: 'Block not found.' };
|
||||
|
||||
const siblings = await prisma.block.findMany({
|
||||
where: { scheduleId: block.scheduleId, id: { not: blockId } },
|
||||
select: { id: true, startMinutes: true, endMinutes: true }
|
||||
});
|
||||
|
||||
const validation = validateBlock(input, siblings);
|
||||
if (!validation.ok) return validation;
|
||||
|
||||
const proclamationIds = uniqueIds(input.proclamationIds);
|
||||
await prisma.block.update({
|
||||
where: { id: blockId },
|
||||
data: {
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
proclamations: {
|
||||
deleteMany: {},
|
||||
create: proclamationIds.map((proclamationId, index) => ({
|
||||
proclamationId,
|
||||
sortOrder: index
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, id: blockId };
|
||||
}
|
||||
|
||||
export async function deleteBlock(userId: string, blockId: string): Promise<BlockMutationResult> {
|
||||
const block = await prisma.block.findFirst({
|
||||
where: { id: blockId, schedule: { userId } },
|
||||
select: { id: true }
|
||||
});
|
||||
if (!block) return { ok: false, error: 'Block not found.' };
|
||||
|
||||
await prisma.block.delete({ where: { id: blockId } });
|
||||
return { ok: true, id: blockId };
|
||||
}
|
||||
Reference in New Issue
Block a user