feat(booking-templates): per-company opt-in hiding of system templates (#2004)
* feat(booking-templates): per-company opt-in hiding of system templates Users cannot delete or hide the 26 standard konteringspaket, which clutter the settings panel and every template picker. Deletion stays off the table (shared global rows); instead a company can now hide individual system templates for itself only. - New booking_template_hidden table (insert=hide, delete=unhide), RLS gated on active company + write role; nothing hidden by default - POST/DELETE /api/settings/booking-templates/[id]/hide (system templates only; company/team templates keep their real delete path) - List route decorates rows with per-company is_hidden; pickers filter them out; the settings panel shows hidden ones in a collapsed restore section so hiding is never silent - Classified in full-archive-export exclusions (UI preference, not rakenskapsinformation) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL * fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book Skeptic + CodeRabbit findings on #2004, one pass: - hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no UPDATE policy on purpose, so the DO UPDATE conflict arm turned a concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape - bth_insert policy additionally requires the referenced template to be an active system template (migration is unmerged, edited in place); negative pg test for company templates - BulkBookDialog excludes templates hidden by the company (was reading the table directly and ignoring hides) - panel shows the failure toast when the hide/unhide fetch itself rejects - picker category chips built from the hidden-filtered list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1319,3 +1319,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-27] Dropped VAT cadence localStorage persistence from PR #1998 (kept the settings-row gate): skeptic pass refuted it twice (SSR hydration mismatch from render-phase localStorage read; persisting a cadence that deviates from moms_period keeps the filing pipeline open on the wrong period type with no downstream period-type validation). The mount-time re-seed from moms_period is the self-healing control; FyPicker already persists the rakenskapsar pick.
|
||||
[2026-08-27] Invite-only brand signup ships accepting a low-severity allowlist enumeration residual: POST /api/auth/signup returns 403 for a non-allowlisted email vs 200/400 for an allowlisted one, and the 403 short-circuits before GoTrue, so it is captcha-free and unthrottled: someone with candidate emails can test which are on a brand's allowlist. Not closed because (a) the app deliberately never holds the Turnstile secret (it lives in Supabase/GoTrue; a repo test forbids TURNSTILE_SECRET_KEY in app env), and (b) the clear "you're not invited, go to Accounted" redirect UX inherently reveals the verdict. It leaks membership of guessed emails, not the list, and no ledger/credential data. Follow-up option if it matters later: add signup-endpoint rate limiting. The related fail-OPEN (a brands-table error was read as unbranded, opening invite-only signup during a DB blip) WAS fixed: the gate now returns lookupFailed and both signup routes answer 503.
|
||||
[2026-08-27] Byrå-team invite acceptance was implemented only in POST /api/team/accept, which the email+password signup flow never reaches before the dashboard (hosted requires email confirmation, so the register page gets no session to run its client-side accept, and the auth callback + onboarding recovery only knew company_invitations). A new byrå admin therefore landed on /onboarding instead of /clients. Fix: one shared server helper acceptPendingTeamInviteByToken (lib/company/pending-invites.ts), called by the route (unchanged HTTP contract), the auth callback (accepts BEFORE landing resolves, so resolveLandingDestination sees the membership and sends admins to /clients; cookie cleared on success), and acceptPendingInviteByToken (onboarding/select-company recovery, tries company then team). hasPendingInviteForEmail now checks both invite tables. No migration.
|
||||
[2026-08-28] Per-company hiding of standardmallar via new booking_template_hidden table (insert=hide, delete=unhide), not is_active or a library column: system template rows are shared globally, so per-company state must live beside them; hiding is opt-in per company and restorable in settings (user request).
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Tests for POST/DELETE /api/settings/booking-templates/[id]/hide.
|
||||
*
|
||||
* Hiding is opt-in and per-company: a hide row is written for the ACTIVE
|
||||
* company only, and only for system templates (company/team templates have a
|
||||
* real delete path already). The tests lock in both properties, plus the
|
||||
* idempotent unhide.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { POST, DELETE } from '../route'
|
||||
|
||||
interface CapturedCall {
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
/** Chainable builder recording calls; resolves queued {data,error} per from(). */
|
||||
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
|
||||
const calls: CapturedCall[] = []
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'upsert', 'delete', 'maybeSingle']) {
|
||||
b[m] = (...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
}
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
return b
|
||||
}
|
||||
return {
|
||||
supabase: {
|
||||
from: (table: string) => {
|
||||
calls.push({ method: 'from', args: [table] })
|
||||
return makeBuilder()
|
||||
},
|
||||
},
|
||||
calls,
|
||||
}
|
||||
}
|
||||
|
||||
const idParams = { params: Promise.resolve({ id: 'tpl-1' }) }
|
||||
|
||||
const SYSTEM_TEMPLATE = {
|
||||
data: { id: 'tpl-1', is_system: true, is_active: true },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
function auth(supabase: unknown) {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
function req(method: 'POST' | 'DELETE') {
|
||||
return createMockRequest('/api/settings/booking-templates/tpl-1/hide', { method })
|
||||
}
|
||||
|
||||
describe('POST /api/settings/booking-templates/[id]/hide', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
expect((await POST(req('POST'), idParams)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
expect((await POST(req('POST'), idParams)).status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 404 when the template does not exist', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([{ data: null }])
|
||||
auth(supabase)
|
||||
const { status } = await parseJsonResponse(await POST(req('POST'), idParams))
|
||||
expect(status).toBe(404)
|
||||
expect(calls.find((c) => c.method === 'upsert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 404 for a retired (inactive) template', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { id: 'tpl-1', is_system: true, is_active: false } },
|
||||
])
|
||||
auth(supabase)
|
||||
const { status } = await parseJsonResponse(await POST(req('POST'), idParams))
|
||||
expect(status).toBe(404)
|
||||
expect(calls.find((c) => c.method === 'upsert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 400 for a non-system template', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { id: 'tpl-1', is_system: false, is_active: true } },
|
||||
])
|
||||
auth(supabase)
|
||||
const { status } = await parseJsonResponse(await POST(req('POST'), idParams))
|
||||
expect(status).toBe(400)
|
||||
expect(calls.find((c) => c.method === 'upsert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 500 when the upsert fails', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
SYSTEM_TEMPLATE,
|
||||
{ error: { message: 'boom' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const { status } = await parseJsonResponse(await POST(req('POST'), idParams))
|
||||
expect(status).toBe(500)
|
||||
})
|
||||
|
||||
it('hides a system template for the active company on the happy path', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([SYSTEM_TEMPLATE, { data: null }])
|
||||
auth(supabase)
|
||||
const { status } = await parseJsonResponse(await POST(req('POST'), idParams))
|
||||
expect(status).toBe(200)
|
||||
const upsert = calls.find((c) => c.method === 'upsert')
|
||||
expect(upsert?.args[0]).toEqual({
|
||||
template_id: 'tpl-1',
|
||||
company_id: 'company-1',
|
||||
hidden_by: 'user-1',
|
||||
})
|
||||
// ignoreDuplicates is load-bearing: the table has no UPDATE policy, so a
|
||||
// DO UPDATE conflict arm would be rejected by RLS on a concurrent re-hide.
|
||||
expect(upsert?.args[1]).toEqual({
|
||||
onConflict: 'template_id,company_id',
|
||||
ignoreDuplicates: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/settings/booking-templates/[id]/hide', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
expect((await DELETE(req('DELETE'), idParams)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
expect((await DELETE(req('DELETE'), idParams)).status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 500 when the delete fails', async () => {
|
||||
const { supabase } = createCapturingSupabase([{ error: { message: 'boom' } }])
|
||||
auth(supabase)
|
||||
const { status } = await parseJsonResponse(await DELETE(req('DELETE'), idParams))
|
||||
expect(status).toBe(500)
|
||||
})
|
||||
|
||||
it('unhides scoped to the active company, idempotently', async () => {
|
||||
// Zero deleted rows is still success: unhide may race a double click.
|
||||
const { supabase, calls } = createCapturingSupabase([{ data: null }])
|
||||
auth(supabase)
|
||||
const { status } = await parseJsonResponse(await DELETE(req('DELETE'), idParams))
|
||||
expect(status).toBe(200)
|
||||
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqCalls).toContainEqual(['template_id', 'tpl-1'])
|
||||
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* POST /api/settings/booking-templates/[id]/hide
|
||||
*
|
||||
* Hide a system template for the current company. Opt-in and per-company:
|
||||
* nothing is hidden by default, and a hide row never affects any other
|
||||
* company. Company/team templates are excluded on purpose: they already have
|
||||
* a real delete path, and hiding them would just be a confusing second one.
|
||||
*
|
||||
* DELETE /api/settings/booking-templates/[id]/hide
|
||||
*
|
||||
* Unhide (restore) the template for the current company.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'booking_template.hide',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, user } = ctx
|
||||
|
||||
// Only an existing, active SYSTEM template can be hidden. RLS on
|
||||
// booking_template_hidden scopes the write to the active company; this
|
||||
// check scopes it to the right kind of template.
|
||||
const { data: template, error: templateError } = await supabase
|
||||
.from('booking_template_library')
|
||||
.select('id, is_system, is_active')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (templateError) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(templateError) }, { status: 500 })
|
||||
}
|
||||
if (!template || !template.is_active) {
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
if (!template.is_system) {
|
||||
return NextResponse.json({ error: 'Only system templates can be hidden' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ignoreDuplicates makes the conflict arm DO NOTHING. The table has no
|
||||
// UPDATE policy (insert-or-delete only), so a DO UPDATE arm would be
|
||||
// rejected by RLS and turn a concurrent re-hide into a 500.
|
||||
const { error } = await supabase
|
||||
.from('booking_template_hidden')
|
||||
.upsert(
|
||||
{ template_id: id, company_id: companyId, hidden_by: user.id },
|
||||
{ onConflict: 'template_id,company_id', ignoreDuplicates: true },
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'booking_template.unhide',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Deleting a row that does not exist is a no-op success: unhide is
|
||||
// idempotent, and the panel may race a double click.
|
||||
const { error } = await supabase
|
||||
.from('booking_template_hidden')
|
||||
.delete()
|
||||
.eq('template_id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for GET /api/settings/booking-templates.
|
||||
*
|
||||
* Focused on the is_hidden decoration: every row carries the per-company flag,
|
||||
* a failed hidden lookup falls back to "nothing hidden" (showing extra
|
||||
* templates is the safe direction), and hidden rows are still RETURNED so the
|
||||
* settings panel can offer restore; filtering is the pickers' job.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('11111111-1111-4111-8111-111111111111'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('11111111-1111-4111-8111-111111111111'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
/** Chainable builder resolving queued {data,error} per from() in call order. */
|
||||
function createQueuedSupabase(results: { data?: unknown; error?: unknown }[]) {
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'or', 'order', 'maybeSingle']) {
|
||||
b[m] = () => b
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
return b
|
||||
}
|
||||
return { from: () => makeBuilder() }
|
||||
}
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: 'tpl-1', name: 'Bankavgift', is_system: true },
|
||||
{ id: 'tpl-2', name: 'Eget uttag', is_system: true },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function auth(supabase: unknown) {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
const req = () => createMockRequest('/api/settings/booking-templates', { method: 'GET' })
|
||||
|
||||
describe('GET /api/settings/booking-templates', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
expect((await GET(req(), { params: Promise.resolve({}) })).status).toBe(401)
|
||||
})
|
||||
|
||||
it('marks hidden templates but still returns them', async () => {
|
||||
// from() order: companies, then library / usage / hidden.
|
||||
const supabase = createQueuedSupabase([
|
||||
{ data: { team_id: null } },
|
||||
{ data: TEMPLATES },
|
||||
{ data: [] },
|
||||
{ data: [{ template_id: 'tpl-2' }] },
|
||||
])
|
||||
auth(supabase)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { id: string; is_hidden: boolean }[]
|
||||
}>(await GET(req(), { params: Promise.resolve({}) }))
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(body.data.find((t) => t.id === 'tpl-1')?.is_hidden).toBe(false)
|
||||
expect(body.data.find((t) => t.id === 'tpl-2')?.is_hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to nothing hidden when the hidden lookup fails', async () => {
|
||||
const supabase = createQueuedSupabase([
|
||||
{ data: { team_id: null } },
|
||||
{ data: TEMPLATES },
|
||||
{ data: [] },
|
||||
{ error: { message: 'boom' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { is_hidden: boolean }[]
|
||||
}>(await GET(req(), { params: Promise.resolve({}) }))
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.every((t) => t.is_hidden === false)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -73,7 +73,7 @@ export const GET = withRouteContext(
|
||||
...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []),
|
||||
].join(',')
|
||||
|
||||
const [templatesRes, usageRes] = await Promise.all([
|
||||
const [templatesRes, usageRes, hiddenRes] = await Promise.all([
|
||||
supabase
|
||||
.from('booking_template_library')
|
||||
.select('*')
|
||||
@@ -85,6 +85,10 @@ export const GET = withRouteContext(
|
||||
.from('booking_template_usage')
|
||||
.select('template_id, last_used_at')
|
||||
.eq('company_id', companyId),
|
||||
supabase
|
||||
.from('booking_template_hidden')
|
||||
.select('template_id')
|
||||
.eq('company_id', companyId),
|
||||
])
|
||||
|
||||
if (templatesRes.error) {
|
||||
@@ -98,10 +102,20 @@ export const GET = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// hidden lookup failing is also non-fatal: falling back to "nothing
|
||||
// hidden" shows extra templates, which is the safe direction.
|
||||
const hiddenIds = new Set<string>()
|
||||
if (!hiddenRes.error && hiddenRes.data) {
|
||||
for (const row of hiddenRes.data) {
|
||||
hiddenIds.add(row.template_id)
|
||||
}
|
||||
}
|
||||
|
||||
const templates = templatesRes.data ?? []
|
||||
const decorated = templates.map((t) => ({
|
||||
...t,
|
||||
last_used_at: usageByTemplate.get(t.id) ?? null,
|
||||
is_hidden: hiddenIds.has(t.id),
|
||||
}))
|
||||
|
||||
// Stable-sort: templates with last_used_at come first (most-recent first).
|
||||
|
||||
@@ -57,7 +57,9 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou
|
||||
}, [open, templatesError, toast])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = templates
|
||||
// Templates hidden for this company (opt-in via the settings panel)
|
||||
// never surface in the picker; only settings shows them, for restore.
|
||||
let result = templates.filter((t) => !t.is_hidden)
|
||||
|
||||
// Filter by entity type
|
||||
if (entityType) {
|
||||
@@ -82,9 +84,11 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou
|
||||
return result
|
||||
}, [templates, entityType, selectedCategory, search])
|
||||
|
||||
// Unique categories present in templates
|
||||
// Unique categories present in visible templates. Built from the
|
||||
// hidden-filtered list so a category whose templates are all hidden does
|
||||
// not render a chip that can only ever match nothing.
|
||||
const availableCategories = useMemo(() => {
|
||||
const cats = new Set(templates.map((t) => t.category))
|
||||
const cats = new Set(templates.filter((t) => !t.is_hidden).map((t) => t.category))
|
||||
return Array.from(cats).sort()
|
||||
}, [templates])
|
||||
|
||||
|
||||
@@ -53,7 +53,11 @@ export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Pr
|
||||
// Session-cached (lib/reference-data): opening the dialog costs no
|
||||
// requests once the lists are in the cache. null = still loading.
|
||||
const { templates: cachedTemplates, isLoading: templatesLoading } = useBookingTemplates()
|
||||
const templates: BookingTemplateLibrary[] | null = templatesLoading ? null : cachedTemplates
|
||||
// Templates hidden for this company (settings panel opt-in) never show in
|
||||
// the booking flow; the settings panel is the only surface that lists them.
|
||||
const templates: BookingTemplateLibrary[] | null = templatesLoading
|
||||
? null
|
||||
: cachedTemplates.filter((tt) => !tt.is_hidden)
|
||||
const { periods } = useFiscalPeriods()
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<BookingTemplateLibrary | null>(null)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy } from 'lucide-react'
|
||||
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy, Eye, EyeOff } from 'lucide-react'
|
||||
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { TemplateForm } from '@/components/settings/TemplateForm'
|
||||
@@ -42,6 +42,8 @@ export function BookingTemplatesPanel() {
|
||||
// pickers can never disagree.
|
||||
const { templates, isLoading, error: templatesError } = useBookingTemplates()
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
@@ -78,6 +80,35 @@ export function BookingTemplatesPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide/unhide a system template for the current company only. Opt-in per
|
||||
// company: hidden templates stay listed in the collapsed section below so
|
||||
// nothing ever disappears silently.
|
||||
async function handleToggleHidden(id: string, hide: boolean) {
|
||||
setHidingId(id)
|
||||
try {
|
||||
const res = await fetch(`/api/settings/booking-templates/${id}/hide`, {
|
||||
method: hide ? 'POST' : 'DELETE',
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: hide ? t('toast_hide_failed') : t('toast_unhide_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
void invalidateReferenceData('ref:booking-templates')
|
||||
toast({ title: hide ? t('toast_hidden') : t('toast_unhidden') })
|
||||
} catch {
|
||||
// fetch itself rejected (network); same failure toast as a non-ok status.
|
||||
toast({
|
||||
title: hide ? t('toast_hide_failed') : t('toast_unhide_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setHidingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
// The button is disabled while a run is in flight; this also covers the
|
||||
// keyboard/double-click race before React has re-rendered it.
|
||||
@@ -132,8 +163,11 @@ export function BookingTemplatesPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Group templates by scope
|
||||
const systemTemplates = templates.filter((tt) => tt.is_system)
|
||||
// Group templates by scope. Hidden system templates get their own collapsed
|
||||
// section instead of vanishing: the hide feature is per-company and always
|
||||
// reversible from here.
|
||||
const systemTemplates = templates.filter((tt) => tt.is_system && !tt.is_hidden)
|
||||
const hiddenSystemTemplates = templates.filter((tt) => tt.is_system && tt.is_hidden)
|
||||
const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system)
|
||||
const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system)
|
||||
|
||||
@@ -236,10 +270,67 @@ export function BookingTemplatesPanel() {
|
||||
canEdit={false}
|
||||
canCustomize={canWrite}
|
||||
onCustomize={setActiveTemplate}
|
||||
canHide={canWrite}
|
||||
onHide={(tt) => handleToggleHidden(tt.id, true)}
|
||||
hidingId={hidingId}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hidden system templates: collapsed by default, restore per row.
|
||||
Kept visible as a section so hiding is never silent. */}
|
||||
{hiddenSystemTemplates.length > 0 && (
|
||||
<SettingsGroup>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
aria-expanded={showHidden}
|
||||
className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 shrink-0 transition-transform', !showHidden && '-rotate-90')}
|
||||
/>
|
||||
<span>{t('section_hidden')}</span>
|
||||
<span className="tabular-nums">{hiddenSystemTemplates.length}</span>
|
||||
</button>
|
||||
{showHidden && (
|
||||
<div>
|
||||
{hiddenSystemTemplates.map((tt) => (
|
||||
<div
|
||||
key={tt.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3 transition-colors duration-150 hover:bg-secondary/60"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="truncate text-sm text-muted-foreground">{tt.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TEMPLATE_CATEGORY_LABELS[tt.category]}
|
||||
{tt.entity_type !== 'all' && ` · ${ENTITY_LABELS[tt.entity_type]}`}
|
||||
</span>
|
||||
</span>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggleHidden(tt.id, false)}
|
||||
disabled={hidingId === tt.id}
|
||||
aria-label={t('unhide')}
|
||||
title={t('unhide')}
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{hidingId === tt.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
{/* Team templates */}
|
||||
{teamTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
@@ -314,8 +405,11 @@ function TemplateSection({
|
||||
canDelete,
|
||||
canEdit = false,
|
||||
canCustomize = false,
|
||||
canHide = false,
|
||||
onEdit,
|
||||
onCustomize,
|
||||
onHide,
|
||||
hidingId = null,
|
||||
entityLabels,
|
||||
}: {
|
||||
title: string
|
||||
@@ -327,8 +421,11 @@ function TemplateSection({
|
||||
canDelete: boolean
|
||||
canEdit?: boolean
|
||||
canCustomize?: boolean
|
||||
canHide?: boolean
|
||||
onEdit?: (template: BookingTemplateLibrary) => void
|
||||
onCustomize?: (template: BookingTemplateLibrary) => void
|
||||
onHide?: (template: BookingTemplateLibrary) => void
|
||||
hidingId?: string | null
|
||||
entityLabels: Record<string, string>
|
||||
}) {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
@@ -384,6 +481,23 @@ function TemplateSection({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canHide && onHide && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onHide(tt)}
|
||||
disabled={hidingId === tt.id}
|
||||
aria-label={t('hide')}
|
||||
title={t('hide')}
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{hidingId === tt.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -153,21 +153,34 @@ export default function BulkBookDialog({
|
||||
)
|
||||
|
||||
// Load templates when the dialog opens. RLS scopes to user's companies +
|
||||
// system templates; no company_id filter needed.
|
||||
// system templates; no company_id filter needed. Templates the company hid
|
||||
// (booking_template_hidden, per-company opt-in) are excluded like in the
|
||||
// other pickers; if that lookup fails we show everything (safe direction).
|
||||
useEffect(() => {
|
||||
if (!open || !company) return
|
||||
const companyId = company.id
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoadingTemplates(true)
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('booking_template_library')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('is_system', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
const [templatesRes, hiddenRes] = await Promise.all([
|
||||
supabase
|
||||
.from('booking_template_library')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('is_system', { ascending: false })
|
||||
.order('name', { ascending: true }),
|
||||
supabase
|
||||
.from('booking_template_hidden')
|
||||
.select('template_id')
|
||||
.eq('company_id', companyId),
|
||||
])
|
||||
if (cancelled) return
|
||||
setTemplates((data ?? []) as BookingTemplateLibrary[])
|
||||
const hiddenIds = new Set(
|
||||
(hiddenRes.error ? [] : hiddenRes.data ?? []).map((r) => r.template_id as string),
|
||||
)
|
||||
const rows = (templatesRes.data ?? []) as BookingTemplateLibrary[]
|
||||
setTemplates(rows.filter((tpl) => !hiddenIds.has(tpl.id)))
|
||||
} finally {
|
||||
if (!cancelled) setLoadingTemplates(false)
|
||||
}
|
||||
|
||||
@@ -42,8 +42,15 @@ export class ReferenceFetchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A booking_templates row as the list route returns it, with its last-used stamp. */
|
||||
export type BookingTemplateWithUsage = BookingTemplateLibrary & { last_used_at: string | null }
|
||||
/**
|
||||
* A booking_templates row as the list route returns it, with its last-used
|
||||
* stamp and the per-company hidden flag. `is_hidden` templates stay in the
|
||||
* payload so the settings panel can offer restore; pickers filter them out.
|
||||
*/
|
||||
export type BookingTemplateWithUsage = BookingTemplateLibrary & {
|
||||
last_used_at: string | null
|
||||
is_hidden: boolean
|
||||
}
|
||||
|
||||
export async function fetchFiscalPeriods(companyId: string): Promise<FiscalPeriod[]> {
|
||||
const supabase = createClient()
|
||||
|
||||
@@ -1163,6 +1163,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
bank_connections: 'PSD2 connection state and tokens, not portable',
|
||||
bolagsverket_avtal_acceptances: 'service agreement acceptance state',
|
||||
bolagsverket_subscriptions: 'integration subscription state',
|
||||
booking_template_hidden: 'per-company UI preference (hidden system templates); no bookkeeping content',
|
||||
booking_template_usage: 'usage telemetry',
|
||||
calendar_feeds: 'feed tokens (secrets)',
|
||||
capability_grants: 'entitlement state',
|
||||
|
||||
@@ -2560,8 +2560,15 @@
|
||||
"create_dialog_title": "Create bookkeeping template",
|
||||
"empty_state": "No templates found.",
|
||||
"section_system": "Standard templates",
|
||||
"section_hidden": "Hidden standard templates",
|
||||
"section_team": "Team templates",
|
||||
"section_company": "Company templates",
|
||||
"hide": "Hide",
|
||||
"unhide": "Show again",
|
||||
"toast_hidden": "The template is hidden for this company",
|
||||
"toast_unhidden": "The template is shown again",
|
||||
"toast_hide_failed": "Could not hide the template",
|
||||
"toast_unhide_failed": "Could not restore the template",
|
||||
"th_account": "Account",
|
||||
"th_description": "Description",
|
||||
"th_type": "Type",
|
||||
|
||||
@@ -2560,8 +2560,15 @@
|
||||
"create_dialog_title": "Skapa bokföringsmall",
|
||||
"empty_state": "Inga mallar hittades.",
|
||||
"section_system": "Standardmallar",
|
||||
"section_hidden": "Dolda standardmallar",
|
||||
"section_team": "Teammallar",
|
||||
"section_company": "Företagsmallar",
|
||||
"hide": "Dölj",
|
||||
"unhide": "Visa igen",
|
||||
"toast_hidden": "Mallen döljs för det här företaget",
|
||||
"toast_unhidden": "Mallen visas igen",
|
||||
"toast_hide_failed": "Kunde inte dölja mallen",
|
||||
"toast_unhide_failed": "Kunde inte visa mallen igen",
|
||||
"th_account": "Konto",
|
||||
"th_description": "Beskrivning",
|
||||
"th_type": "Typ",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- =============================================================================
|
||||
-- Booking Template Hidden (per-company opt-in hiding of system templates)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- A company can hide system templates (standardmallar) it never uses so they
|
||||
-- stop cluttering the settings panel and pickers. Stored separately from
|
||||
-- booking_template_library because system templates are shared globally
|
||||
-- (is_system = TRUE, company_id NULL): hiding must be a per-company choice,
|
||||
-- never a mutation of the shared row. Nothing is hidden by default; every row
|
||||
-- here is an explicit action by a write-role member of that company, and it
|
||||
-- only affects that company.
|
||||
--
|
||||
-- One row per (template_id, company_id). Insert to hide, delete to unhide.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.booking_template_hidden (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
template_id UUID NOT NULL REFERENCES public.booking_template_library(id) ON DELETE CASCADE,
|
||||
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
hidden_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (template_id, company_id)
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.booking_template_hidden ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Members of a company can see which templates it hides.
|
||||
DROP POLICY IF EXISTS "bth_select" ON public.booking_template_hidden;
|
||||
CREATE POLICY "bth_select" ON public.booking_template_hidden
|
||||
FOR SELECT USING (
|
||||
company_id IN (SELECT public.user_company_ids())
|
||||
);
|
||||
|
||||
-- Hiding/unhiding is a write action on the ACTIVE company only, gated on the
|
||||
-- non-viewer role like the other settings writes (see 20260702093000). Only
|
||||
-- active SYSTEM templates can be hidden: company/team templates have a real
|
||||
-- delete path, so a hide row for them must not exist even via direct
|
||||
-- PostgREST calls (the API route checks the same thing).
|
||||
DROP POLICY IF EXISTS "bth_insert" ON public.booking_template_hidden;
|
||||
CREATE POLICY "bth_insert" ON public.booking_template_hidden
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = current_active_company_id()
|
||||
AND current_user_can_write()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.booking_template_library t
|
||||
WHERE t.id = template_id AND t.is_system AND t.is_active
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "bth_delete" ON public.booking_template_hidden;
|
||||
CREATE POLICY "bth_delete" ON public.booking_template_hidden
|
||||
FOR DELETE USING (
|
||||
company_id = current_active_company_id() AND current_user_can_write()
|
||||
);
|
||||
|
||||
-- No UPDATE policy on purpose: a hide row is insert-or-delete only.
|
||||
|
||||
-- Lookup index for the list route: all hidden template ids for a company.
|
||||
CREATE INDEX IF NOT EXISTS idx_bth_company
|
||||
ON public.booking_template_hidden (company_id);
|
||||
|
||||
-- Schema reload for PostgREST
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
||||
|
||||
/**
|
||||
* `booking_template_hidden` (migration 20260828100000).
|
||||
*
|
||||
* Per-company opt-in hiding of system templates. The RLS matters here: a hide
|
||||
* row written by company A must never leak to, or be writable from, company B,
|
||||
* and viewers must not be able to hide anything. All writes go through the
|
||||
* authenticated role so the policies are what is actually under test.
|
||||
*/
|
||||
|
||||
async function setActiveCompany(userId: string, companyId: string) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
}
|
||||
|
||||
async function systemTemplateId(): Promise<string> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT id FROM public.booking_template_library WHERE is_system AND is_active LIMIT 1`,
|
||||
)
|
||||
return rows[0].id as string
|
||||
}
|
||||
|
||||
describe('booking_template_hidden RLS', () => {
|
||||
it('lets a write-role member hide and unhide for the active company', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await setActiveCompany(userId, companyId)
|
||||
const templateId = await systemTemplateId()
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[templateId, companyId, userId],
|
||||
)
|
||||
const { rows } = await client.query(
|
||||
`SELECT template_id FROM public.booking_template_hidden WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].template_id).toBe(templateId)
|
||||
|
||||
const del = await client.query(
|
||||
`DELETE FROM public.booking_template_hidden
|
||||
WHERE template_id = $1 AND company_id = $2`,
|
||||
[templateId, companyId],
|
||||
)
|
||||
expect(del.rowCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks hiding for a company that is not the active one', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const other = await seedCompany()
|
||||
// Member of both, but acting in their own company.
|
||||
await insertCompanyMember({ companyId: other.companyId, userId, role: 'owner' })
|
||||
await setActiveCompany(userId, companyId)
|
||||
const templateId = await systemTemplateId()
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[templateId, other.companyId, userId],
|
||||
),
|
||||
).rejects.toThrow(/row-level security/)
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks a viewer from hiding', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
const viewerId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
|
||||
await setActiveCompany(viewerId, companyId)
|
||||
const templateId = await systemTemplateId()
|
||||
|
||||
await withUserContext(viewerId, async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[templateId, companyId, viewerId],
|
||||
),
|
||||
).rejects.toThrow(/row-level security/)
|
||||
})
|
||||
})
|
||||
|
||||
it("does not leak another company's hide rows", async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
const templateId = await systemTemplateId()
|
||||
// Seed A's hide row on the superuser connection so it persists for B's read.
|
||||
await getPool().query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
[templateId, a.companyId, a.userId],
|
||||
)
|
||||
await setActiveCompany(b.userId, b.companyId)
|
||||
|
||||
await withUserContext(b.userId, async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`SELECT template_id FROM public.booking_template_hidden`,
|
||||
)
|
||||
expect(rows).toEqual([])
|
||||
})
|
||||
|
||||
// Cleanup the persisted seed row.
|
||||
await getPool().query(
|
||||
`DELETE FROM public.booking_template_hidden WHERE company_id = $1`,
|
||||
[a.companyId],
|
||||
)
|
||||
})
|
||||
|
||||
it('re-hide via ON CONFLICT DO NOTHING succeeds despite no UPDATE policy', async () => {
|
||||
// The route upserts with ignoreDuplicates (DO NOTHING). A DO UPDATE arm
|
||||
// would be rejected by RLS here (no UPDATE policy on purpose), so this
|
||||
// pins the exact conflict shape the route sends: second hide = no-op 0
|
||||
// rows, not an error.
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await setActiveCompany(userId, companyId)
|
||||
const templateId = await systemTemplateId()
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const first = await client.query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (template_id, company_id) DO NOTHING`,
|
||||
[templateId, companyId, userId],
|
||||
)
|
||||
expect(first.rowCount).toBe(1)
|
||||
const second = await client.query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (template_id, company_id) DO NOTHING`,
|
||||
[templateId, companyId, userId],
|
||||
)
|
||||
expect(second.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks hiding a non-system (company) template even via direct insert', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await setActiveCompany(userId, companyId)
|
||||
// A company-scoped template: has a real delete path, must not be hideable.
|
||||
const { rows } = await getPool().query(
|
||||
`INSERT INTO public.booking_template_library
|
||||
(company_id, created_by, name, description, category, entity_type, is_system, lines)
|
||||
VALUES ($1, $2, 'Egen mall', '', 'other', 'all', FALSE, '[]'::jsonb)
|
||||
RETURNING id`,
|
||||
[companyId, userId],
|
||||
)
|
||||
const companyTemplateId = rows[0].id as string
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[companyTemplateId, companyId, userId],
|
||||
),
|
||||
).rejects.toThrow(/row-level security/)
|
||||
})
|
||||
|
||||
await getPool().query(`DELETE FROM public.booking_template_library WHERE id = $1`, [
|
||||
companyTemplateId,
|
||||
])
|
||||
})
|
||||
|
||||
it('enforces one hide row per (template, company)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await setActiveCompany(userId, companyId)
|
||||
const templateId = await systemTemplateId()
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[templateId, companyId, userId],
|
||||
)
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[templateId, companyId, userId],
|
||||
),
|
||||
).rejects.toThrow(/duplicate key/)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user