Compare commits

...

7 Commits

Author SHA1 Message Date
Marcel
929acf6964 style(persons): apply prettier formatting to PersonCard hasNoName derived
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Pure formatting (line wrap) so the file passes prettier --check; no behaviour
change.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:20:00 +02:00
Marcel
362672cdbf test(person): pin query count-parity and delete FK-detach ordering
Add countByFilter parity coverage for the query (LIKE) path so the shared
FILTER_WHERE slice and count can't drift, and an integration test proving
deletePerson detaches a person referenced as both sender and receiver before
delete — the documents survive (sender nulled, receiver link removed) with no
FK orphan.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:19:06 +02:00
Marcel
1e3e420860 fix(person): report honest totals on the non-paged top-N persons path
The legacy sort=documentCount path wrapped its result with paged(top, 0,
safeSize, top.size()), so totalElements/pageSize looked like a paged slice of
a larger set when in fact the top-N query returns the complete result. Add a
dedicated PersonSearchResult.topN factory that reports reality — totalElements
= returned count, pageSize = that count, totalPages = 1 (0 when empty) — and
pin both the populated and empty semantics with controller tests.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:19:00 +02:00
Marcel
3a758393bf refactor(shared): extract hasWriteAll(locals) permission helper
The locals.user.groups.some(...WRITE_ALL) derivation was copy-pasted across
the persons directory, persons review and the two document loaders touched by
this PR. Extract a single tested hasWriteAll(locals) helper in
$lib/shared/server and reuse it, removing the ad-hoc casts.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:14:00 +02:00
Marcel
1a0be4130e fix(persons): make the show-all switch accessible name match its visible text
The role="switch" toggle set a fixed aria-label of "Zu prüfen (N)" while its
visible text flips to "Alle anzeigen" when active — a visible-text /
accessible-name mismatch (WCAG 2.5.3 Label in Name). Drop the aria-label so
the visible text is the accessible name; aria-checked carries the state.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:12:01 +02:00
Marcel
98f8c0129a fix(persons): label rename fields with dedicated first/last-name keys
The triage rename form reused persons_filter_type_person ("Person") and
persons_section_details ("Angaben zur Person") as the first/last-name field
labels, so a screen reader announced the wrong name for each input. Add
dedicated persons_field_first_name / persons_field_last_name keys (de/en/es)
and use them.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:11:32 +02:00
Marcel
79e9cc5a2b fix(persons): key the unconfirmed badge off provisional only
Align PersonCard's "unbestätigt" badge with the authoritative provisional
flag so the badge, the "Zu prüfen (N)" count and the /persons/review triage
list can never disagree. Empty/"?" name handling is now a separate
crash-safety concern: it still routes to the neutral placeholder glyph
(never a "?" initial) but no longer implies a badge on its own.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:10:16 +02:00
18 changed files with 226 additions and 47 deletions

View File

@@ -51,12 +51,14 @@ public class PersonController {
@RequestParam(required = false) String sort,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
// Legacy top-N-by-document-count path (reader dashboard): preserved, now wrapped in the
// paged contract so /api/persons always returns one shape.
// Legacy top-N-by-document-count path (reader dashboard): preserved, wrapped in the
// same envelope so /api/persons always returns one shape. It is explicitly NON-paged —
// the top-N query returns the complete result, so PersonSearchResult.topN reports an
// honest totalElements (= returned count) instead of pretending to be a page slice.
if ("documentCount".equals(sort) && q == null) {
int safeSize = Math.min(size, 50);
List<PersonSummaryDTO> top = personService.findTopByDocumentCount(safeSize);
return ResponseEntity.ok(PersonSearchResult.paged(top, 0, safeSize, top.size()));
return ResponseEntity.ok(PersonSearchResult.topN(top));
}
PersonFilter filter = PersonFilter.builder()

View File

@@ -33,4 +33,18 @@ public record PersonSearchResult(
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new PersonSearchResult(slice, totalElements, pageNumber, pageSize, totalPages);
}
/**
* Non-paged factory for the legacy {@code sort=documentCount} top-N dashboard path.
* That query returns the <em>complete</em> result in one shot — there is no further page
* to fetch — so the envelope reports reality rather than pretending to be a slice of a
* larger set: {@code totalElements} equals the number of rows actually returned,
* {@code pageSize} equals that same count, and {@code totalPages} is 1 (or 0 when empty).
* This avoids the earlier ambiguity where {@code totalElements} looked like a paged total.
*/
public static PersonSearchResult topN(List<PersonSummaryDTO> all) {
int count = all.size();
int totalPages = count == 0 ? 0 : 1;
return new PersonSearchResult(all, count, 0, count, totalPages);
}
}

View File

@@ -175,6 +175,36 @@ class PersonControllerTest {
.andExpect(jsonPath("$.items[0].firstName").value("Käthe"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_topByDocumentCount_isNonPaged_totalElementsEqualsReturnedCount() throws Exception {
// The top-N dashboard path is deliberately NON-paged: it returns the complete result
// (no further page exists), so totalElements equals the number of rows returned and
// totalPages is 1. Pinned so nobody "fixes" it into a misleading paged total.
when(personService.findTopByDocumentCount(50))
.thenReturn(List.of(mockPersonSummary("Käthe", "Raddatz"),
mockPersonSummary("Hans", "Müller")));
mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items.length()").value(2))
.andExpect(jsonPath("$.totalElements").value(2))
.andExpect(jsonPath("$.pageNumber").value(0))
.andExpect(jsonPath("$.pageSize").value(2))
.andExpect(jsonPath("$.totalPages").value(1));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_topByDocumentCount_emptyResult_reportsZeroPages() throws Exception {
when(personService.findTopByDocumentCount(50)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(0))
.andExpect(jsonPath("$.totalPages").value(0));
}
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
return new PersonSummaryDTO() {
public java.util.UUID getId() { return UUID.randomUUID(); }

View File

@@ -648,6 +648,21 @@ class PersonRepositoryTest {
assertThat(count).isEqualTo(1);
}
@Test
void countByFilter_query_matchesSliceSize() {
// The whole point of the shared FILTER_WHERE is that the slice and the count can never
// drift. Pin the query (LIKE) path explicitly: countByFilter must equal the slice size
// so a future edit to one query's LIKE clause is caught.
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, "Verlag", 50, 0);
long count = personRepository.countByFilter(null, null, null, null, false, "Verlag");
assertThat(count).isEqualTo(slice.size());
assertThat(count).isEqualTo(1);
}
@Test
void findByFilter_projectsDocumentCount() {
seedDirectoryFixture();

View File

@@ -2,6 +2,9 @@ package org.raddatz.familienarchiv.person;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonRepository;
@@ -13,6 +16,11 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@@ -24,6 +32,9 @@ class PersonServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired PersonService personService;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@PersistenceContext EntityManager entityManager;
@Test
void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
@@ -112,4 +123,48 @@ class PersonServiceIntegrationTest {
assertThat(personRepository.findById(target.getId())).isEmpty();
}
@Test
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
// A person referenced as BOTH a document sender and a document receiver must delete
// cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
// the documents themselves survive.
Person target = personRepository.save(Person.builder()
.firstName("Weg").lastName("Person").provisional(true).build());
Person bystander = personRepository.save(Person.builder()
.firstName("Bleibt").lastName("Hier").build());
Document sent = documentRepository.save(Document.builder()
.title("Sent letter").originalFilename("sent.pdf")
.status(DocumentStatus.UPLOADED).sender(target).build());
Document received = documentRepository.save(Document.builder()
.title("Received letter").originalFilename("received.pdf")
.status(DocumentStatus.UPLOADED).sender(bystander)
.receivers(new java.util.HashSet<>(Set.of(target))).build());
// Persist the fixture and detach everything so the native @Modifying deletes operate on
// the database directly without the persistence context holding stale references that
// would re-flush a now-deleted person as a transient association.
entityManager.flush();
entityManager.clear();
personService.deletePerson(target.getId());
// Native @Modifying queries bypass the persistence context — clear it so the asserting
// reads observe the post-delete database state, not stale managed entities.
entityManager.flush();
entityManager.clear();
assertThat(personRepository.findById(target.getId())).isEmpty();
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
assertThat(reloadedSent.getSender()).isNull();
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
assertThat(reloadedReceived.getReceivers())
.noneMatch(p -> p.getId().equals(target.getId()));
// The other person and the documents themselves survive the delete.
assertThat(personRepository.findById(bystander.getId())).isPresent();
}
}

View File

@@ -153,6 +153,8 @@
"persons_review_delete_confirm_text": "Diese Person wird endgültig gelöscht. Dokumentverweise bleiben erhalten, verlieren aber diese Person.",
"persons_review_delete_confirm_button": "Person löschen",
"persons_review_merge_label": "Mit welcher Person zusammenführen?",
"persons_field_first_name": "Vorname",
"persons_field_last_name": "Nachname",
"persons_new_heading": "Neue Person",
"persons_section_details": "Angaben zur Person",
"person_edit_heading": "Person bearbeiten",

View File

@@ -153,6 +153,8 @@
"persons_review_delete_confirm_text": "This person will be permanently deleted. Document references are kept but lose this person.",
"persons_review_delete_confirm_button": "Delete person",
"persons_review_merge_label": "Merge into which person?",
"persons_field_first_name": "First name",
"persons_field_last_name": "Last name",
"persons_new_heading": "New person",
"persons_section_details": "Person details",
"person_edit_heading": "Edit person",

View File

@@ -153,6 +153,8 @@
"persons_review_delete_confirm_text": "Esta persona se eliminará de forma permanente. Las referencias de documentos se conservan pero pierden a esta persona.",
"persons_review_delete_confirm_button": "Eliminar persona",
"persons_review_merge_label": "¿Fusionar con qué persona?",
"persons_field_first_name": "Nombre",
"persons_field_last_name": "Apellido",
"persons_new_heading": "Nueva persona",
"persons_section_details": "Datos de la persona",
"person_edit_heading": "Editar persona",

View File

@@ -8,27 +8,28 @@ type Person = components['schemas']['PersonSummaryDTO'];
let { person }: { person: Person } = $props();
// A card is "unconfirmed" when the importer could not identify the person: the explicit
// provisional flag, the placeholder UNKNOWN type, or a literal "?" name from the spreadsheet.
const isUnconfirmed = $derived(
person.provisional === true ||
person.personType === 'UNKNOWN' ||
person.lastName === '?' ||
(person.lastName ?? '').trim() === ''
// "Unconfirmed" is exactly the `provisional` flag — the authoritative signal the importer
// sets and the triage flow clears. The badge, the "Zu prüfen (N)" count and the
// /persons/review list all key off this same flag, so badge ⇔ count ⇔ triage can never drift.
const isUnconfirmed = $derived(person.provisional === true);
// An empty / "?" last name is a separate, purely defensive concern: it must not crash the
// initials branch (reading lastName[0] on null throws) and must never render a "?" initial.
// It implies the placeholder glyph but — on its own — no "unbestätigt" badge.
const hasNoName = $derived(
person.lastName == null || person.lastName.trim() === '' || person.lastName === '?'
);
// A non-PERSON type (institution/group) gets a glyph; everything that is a real, confirmed
// person gets initials. Unconfirmed persons never get a "?" initial — they get the neutral
// placeholder glyph instead. Reading lastName[0] on a null/empty name would throw, so the
// initials branch is gated on a non-empty name.
// A non-PERSON type (institution/group) gets a typed glyph; a confirmed, named person gets
// initials. Provisional entries and nameless entries fall back to the neutral placeholder glyph.
const showGlyph = $derived(
isUnconfirmed || (person.personType != null && person.personType !== 'PERSON')
isUnconfirmed || hasNoName || (person.personType != null && person.personType !== 'PERSON')
);
const initials = $derived.by(() => {
const first = person.firstName?.[0] ?? '';
const last = person.lastName?.[0] ?? '';
return (first || last) + last;
return first ? first + last : last;
});
const documentCount = $derived(person.documentCount ?? 0);
@@ -43,7 +44,7 @@ const documentCount = $derived(person.documentCount ?? 0);
<div
class={[
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full font-serif text-base font-bold transition-colors',
isUnconfirmed ? 'bg-muted text-ink-2' : 'bg-primary text-primary-fg'
isUnconfirmed || hasNoName ? 'bg-muted text-ink-2' : 'bg-primary text-primary-fg'
]}
>
{#if showGlyph}
@@ -98,7 +99,6 @@ const documentCount = $derived(person.documentCount ?? 0);
<!-- State conveyed by text + the muted placeholder shape, never colour alone (WCAG 1.4.1). -->
<span
class="inline-flex items-center gap-1 rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-xs font-semibold text-ink-2"
aria-label={m.person_badge_unconfirmed()}
>
<svg
class="h-3 w-3"

View File

@@ -32,16 +32,18 @@ describe('PersonCard — confirmed person', () => {
});
});
describe('PersonCard — unconfirmed / malformed (regression: null-lastName crash)', () => {
describe('PersonCard — unconfirmed badge keys off provisional only (badge ⇔ count ⇔ triage parity)', () => {
it('renders without throwing when lastName is null', async () => {
// Before the fix, `lastName[0]` threw at render for a null lastName.
// Before the fix, `lastName[0]` threw at render for a null lastName. Empty-name
// crash-safety is a SEPARATE concern from the badge: the placeholder glyph renders
// regardless, but the "unbestätigt" badge only fires when provisional is true.
const person = makePerson({
lastName: null as unknown as string,
displayName: '?',
provisional: true
});
render(PersonCard, { props: { person } });
// No throw + the placeholder avatar (an <img>) is present, never a "?" initial.
// No throw + provisional → the badge is shown.
await expect.element(page.getByText('unbestätigt')).toBeVisible();
});
@@ -50,17 +52,36 @@ describe('PersonCard — unconfirmed / malformed (regression: null-lastName cras
await expect.element(page.getByText('unbestätigt')).toBeVisible();
});
it('shows a placeholder (no "?" initial) for a "?" name', async () => {
it('does NOT show the badge for a "?" name when not provisional', async () => {
// Empty/"?" name alone is no longer treated as unconfirmed — only `provisional` is.
// This keeps the badge in lockstep with needsReviewCount and the /persons/review list.
render(PersonCard, {
props: { person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' }) }
props: {
person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' })
}
});
await expect.element(page.getByText('unbestätigt')).toBeVisible();
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
it('treats an UNKNOWN type as unconfirmed', async () => {
it('does NOT show the badge for an UNKNOWN type when not provisional', async () => {
render(PersonCard, {
props: { person: makePerson({ personType: 'UNKNOWN', displayName: 'Unklar' }) }
});
await expect.element(page.getByText('unbestätigt')).toBeVisible();
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
it('renders the placeholder glyph (never a "?" initial) for an empty name even without provisional', async () => {
// Crash-safety branch: a null/empty lastName must not throw and must not show "?".
render(PersonCard, {
props: {
person: makePerson({
firstName: undefined,
lastName: null as unknown as string,
displayName: 'Unbekannt'
})
}
});
await expect.element(page.getByText('Unbekannt')).toBeVisible();
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
});

View File

@@ -132,11 +132,13 @@ const chipInactive = 'border-line bg-surface text-ink hover:bg-muted';
<!-- Show-all / Zu prüfen toggle: transcriber-only, reveals the import noise. -->
{#if canWrite}
<!-- No aria-label: the visible text IS the accessible name (WCAG 2.5.3 Label in Name).
It flips between "Zu prüfen (N)" (off) and "Alle anzeigen" (on) and aria-checked
carries the toggle state, so the announced name always matches what the user reads. -->
<button
type="button"
role="switch"
aria-checked={review}
aria-label={m.persons_toggle_needs_review({ count: needsReviewCount })}
class={[chipBase, 'sm:ml-auto', review ? chipActive : chipInactive]}
onclick={() => setReview(!review)}
>

View File

@@ -99,7 +99,7 @@ const deleteBtn =
<input type="hidden" name="id" value={person.id} />
<input type="hidden" name="personType" value={person.personType ?? 'PERSON'} />
<label class="flex flex-col gap-1 text-sm">
<span class="font-sans text-ink-2">{m.persons_filter_type_person()}</span>
<span class="font-sans text-ink-2">{m.persons_field_first_name()}</span>
<input
name="firstName"
bind:value={renameFirstName}
@@ -107,7 +107,7 @@ const deleteBtn =
/>
</label>
<label class="flex flex-1 flex-col gap-1 text-sm">
<span class="font-sans text-ink-2">{m.persons_section_details()}</span>
<span class="font-sans text-ink-2">{m.persons_field_last_name()}</span>
<input
name="lastName"
required

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { hasWriteAll } from './permissions';
type Locals = { user?: { groups?: { permissions: string[] }[] } };
const localsWith = (permissions: string[][]): Locals => ({
user: { groups: permissions.map((p) => ({ permissions: p })) }
});
describe('hasWriteAll', () => {
it('returns true when a group grants WRITE_ALL', () => {
expect(hasWriteAll(localsWith([['READ_ALL', 'WRITE_ALL']]))).toBe(true);
});
it('returns true when WRITE_ALL is in any of several groups', () => {
expect(hasWriteAll(localsWith([['READ_ALL'], ['WRITE_ALL']]))).toBe(true);
});
it('returns false when no group grants WRITE_ALL', () => {
expect(hasWriteAll(localsWith([['READ_ALL'], ['ANNOTATE_ALL']]))).toBe(false);
});
it('returns false for an anonymous user (no locals.user)', () => {
expect(hasWriteAll({})).toBe(false);
});
it('returns false when the user has no groups', () => {
expect(hasWriteAll({ user: {} })).toBe(false);
});
});

View File

@@ -0,0 +1,14 @@
/**
* Server-side permission predicates derived from the authenticated user in `locals`.
*
* The user shape is intentionally narrowed to the only field these checks read
* (`groups[].permissions`) so the helper works against `App.Locals` without importing it.
*/
type PermissionLocals = {
user?: { groups?: { permissions: string[] }[] } | null;
};
/** True when any of the user's groups grants WRITE_ALL. False for anonymous users. */
export function hasWriteAll(locals: PermissionLocals): boolean {
return locals.user?.groups?.some((group) => group.permissions.includes('WRITE_ALL')) ?? false;
}

View File

@@ -2,6 +2,7 @@ import { error, fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
export async function load({
params,
@@ -15,11 +16,7 @@ export async function load({
depends: (dep: string) => void;
}) {
depends('app:document');
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
const { id } = params;
const api = createApiClient(fetch);

View File

@@ -2,6 +2,7 @@ import { fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/shared/api.server';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
export async function load({
fetch,
@@ -12,11 +13,7 @@ export async function load({
locals: App.Locals;
url: URL;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw redirect(303, '/');
if (!hasWriteAll(locals)) throw redirect(303, '/');
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';

View File

@@ -1,6 +1,7 @@
import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
const PAGE_SIZE = 50;
@@ -21,10 +22,7 @@ export async function load({ url, fetch, locals }) {
const api = createApiClient(fetch);
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
const canWrite = hasWriteAll(locals);
const filters = {
q: q || undefined,

View File

@@ -1,14 +1,12 @@
import { error, fail } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
const PAGE_SIZE = 50;
export async function load({ url, fetch, locals }) {
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
const canWrite = hasWriteAll(locals);
const page = Math.max(0, Number.parseInt(url.searchParams.get('page') ?? '0', 10) || 0);
const api = createApiClient(fetch);