S16 postgresql opslag added
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s

This commit is contained in:
2026-11-04 17:10:25 +01:00
parent f59cad115a
commit 694bf3fa86
89 changed files with 3087 additions and 252 deletions

View File

@@ -1,4 +1,4 @@
import { test, expect, Page } from '@playwright/test';
import { test, expect, Page, APIRequestContext } from '@playwright/test';
import type { School } from '../src/app/core/api-models';
const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
@@ -6,11 +6,23 @@ let schoolToDelete: number | undefined;
test.afterEach(async ({ request }) => {
if (schoolToDelete !== undefined) {
await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`);
await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`, {
headers: await revision(request, schoolToDelete),
});
schoolToDelete = undefined;
}
});
async function revision(
request: APIRequestContext,
schoolId: number,
): Promise<Record<string, string>> {
const school = (await (
await request.get(`${backend}/api/dance-schools/${schoolId}`)
).json()) as School;
return { 'X-School-Revision': String(school.revision) };
}
async function tab(page: Page, label: string) {
await page
.getByRole('navigation', { name: 'Dansschool onderdelen' })
@@ -113,6 +125,7 @@ test('manages a school, shared memberships and shows through the real REST API',
const dancerResponse = await request.post(
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
{
headers: await revision(request, schoolToDelete),
data: { name, birthDate: '1990-01-01' },
},
);
@@ -120,14 +133,17 @@ test('manages a school, shared memberships and shows through the real REST API',
const groupResponse = await request.post(
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
{
headers: await revision(request, schoolToDelete),
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
},
);
const group = (await groupResponse.json()) as { id: number };
await request.put(
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
{ headers: await revision(request, schoolToDelete) },
);
}
await page.reload();
await tab(page, 'Shows');
await page.getByRole('button', { name: 'Show toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
@@ -214,12 +230,14 @@ test('links a coach and finalizes performances spread across multiple dates', as
schoolToDelete = school.id;
const base = `${backend}/api/dance-schools/${school.id}`;
const dancerResponse = await request.post(`${base}/dancers`, {
headers: await revision(request, school.id),
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
});
const dancer = (await dancerResponse.json()) as { id: number };
const groupIds: number[] = [];
for (const name of ['Preschool', 'Adults']) {
const response = await request.post(`${base}/dance-groups`, {
headers: await revision(request, school.id),
data: {
category: name,
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
@@ -230,9 +248,12 @@ test('links a coach and finalizes performances spread across multiple dates', as
});
const group = (await response.json()) as { id: number };
groupIds.push(group.id);
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`);
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`, {
headers: await revision(request, school.id),
});
}
const showResponse = await request.post(`${base}/shows`, {
headers: await revision(request, school.id),
data: {
name: 'Coach across dates',
preferredDayParts: 1,
@@ -244,6 +265,7 @@ test('links a coach and finalizes performances spread across multiple dates', as
});
const show = (await showResponse.json()) as { id: number };
await request.put(`${base}/shows/${show.id}/assignments`, {
headers: await revision(request, school.id),
data: { assignments: groupIds.map((id) => [id]) },
});
await page.goto(`/schools/${school.id}/shows`);
@@ -271,3 +293,39 @@ test('shows a useful connection error and recovers on retry', async ({ page }) =
await page.getByRole('button', { name: 'Opnieuw proberen' }).click();
await expect(page.getByRole('heading', { name: /Mijn dansscholen/ })).toBeVisible();
});
test('rejects a stale editor in another screen and allows reload and retry', async ({
page,
request,
}) => {
const response = await request.post(`${backend}/api/dance-schools`, {
data: { name: `Concurrent school ${Date.now()}` },
});
const school = (await response.json()) as School;
schoolToDelete = school.id;
const other = await page.context().newPage();
try {
for (const screen of [page, other]) {
await screen.goto(`/schools/${school.id}/overview`);
await screen.getByRole('button', { name: 'School bewerken' }).click();
}
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('First confirmed edit');
await other.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Stale edit');
await save(page);
await other.getByRole('dialog').getByRole('button', { name: 'Opslaan', exact: true }).click();
await expect(other.getByRole('dialog').getByRole('alert')).toContainText('Herlaad');
await expect(other.getByRole('dialog').getByLabel('Naam', { exact: true })).toHaveValue(
'Stale edit',
);
await other.reload();
await expect(
other.getByRole('heading', { name: 'First confirmed edit', exact: true }),
).toBeVisible();
await other.getByRole('button', { name: 'School bewerken' }).click();
await other.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Reloaded edit');
await save(other);
await expect(other.getByRole('heading', { name: 'Reloaded edit', exact: true })).toBeVisible();
} finally {
await other.close();
}
});

View File

@@ -1,5 +1,6 @@
export interface School {
id: number;
revision: number;
name: string;
dancerIds: number[];
danceGroupIds: number[];

View File

@@ -2,6 +2,7 @@ import { DestroyRef, inject, Injectable, signal } from '@angular/core';
export type SchedulingState =
| 'RUNNING'
| 'INTERRUPTED'
| 'OPTIMAL'
| 'VALID'
| 'STALE'

View File

@@ -18,13 +18,17 @@ describe('SchoolApi contract', () => {
it('loads all resources from the same school', () => {
const received = vi.fn();
api.schoolData(7).subscribe(received);
const school = { id: 7, name: 'Studio', dancerIds: [], danceGroupIds: [], showIds: [] };
http.expectOne('/api/dance-schools/7').flush(school);
http.expectOne('/api/dance-schools/7/dancers').flush([]);
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
expect(received).not.toHaveBeenCalled();
http.expectOne('/api/dance-schools/7/shows').flush([]);
http.expectOne('/api/dance-schools/7/coaches').flush([]);
const school = {
id: 7,
name: 'Studio',
revision: 0,
dancerIds: [],
danceGroupIds: [],
showIds: [],
};
http
.expectOne('/api/dance-schools/7/data')
.flush({ school, dancers: [], groups: [], shows: [], coaches: [] });
expect(received).toHaveBeenCalledWith({
school,
dancers: [],
@@ -34,12 +38,32 @@ describe('SchoolApi contract', () => {
});
});
it('sends the loaded school revision without retrying a stale mutation', () => {
api.schoolData(7).subscribe();
http
.expectOne('/api/dance-schools/7/data')
.flush({ school: { id: 7, revision: 4 }, dancers: [], groups: [], shows: [], coaches: [] });
const failure = vi.fn();
api.saveDancer(7, { name: 'Sam', birthDate: '2010-05-12' }).subscribe({ error: failure });
const request = http.expectOne('/api/dance-schools/7/dancers');
expect(request.request.headers.get('X-School-Revision')).toBe('4');
request.flush({ message: 'Herlaad de gegevens' }, { status: 409, statusText: 'Conflict' });
expect(failure).toHaveBeenCalledOnce();
});
it('updates an existing entity even when its id is zero', () => {
api.saveSchool({ name: 'Renamed' }, 0).subscribe();
const request = http.expectOne('/api/dance-schools/0');
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual({ name: 'Renamed' });
request.flush({ id: 0, name: 'Renamed', dancerIds: [], danceGroupIds: [], showIds: [] });
request.flush({
id: 0,
name: 'Renamed',
revision: 0,
dancerIds: [],
danceGroupIds: [],
showIds: [],
});
});
it('keeps group details separate from memberships', () => {

View File

@@ -1,7 +1,7 @@
import { SchedulingRun } from './scheduling-status';
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs';
import { Observable, tap } from 'rxjs';
import {
Coach,
DanceGroup,
@@ -20,64 +20,114 @@ import {
export class SchoolApi {
private readonly http = inject(HttpClient);
private readonly base = '/api/dance-schools';
private readonly revisions = new Map<number, number>();
schools(): Observable<School[]> {
return this.http.get<School[]>(this.base);
return this.http.get<School[]>(this.base).pipe(
tap((schools) => {
for (const school of schools) this.revisions.set(school.id, school.revision);
}),
);
}
schoolData(schoolId: number): Observable<SchoolData> {
const base = `${this.base}/${schoolId}`;
return forkJoin({
school: this.http.get<School>(base),
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
shows: this.http.get<Show[]>(`${base}/shows`),
coaches: this.http.get<Coach[]>(`${base}/coaches`),
});
return this.http
.get<SchoolData>(`${this.base}/${schoolId}/data`)
.pipe(tap((data) => this.revisions.set(schoolId, data.school.revision)));
}
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
private options(schoolId: number, expected?: number): { headers: Record<string, string> } {
const revision = expected ?? this.revisions.get(schoolId);
return { headers: revision === undefined ? {} : { 'X-School-Revision': String(revision) } };
}
saveSchool(body: SchoolRequest, id?: number, revision?: number): Observable<School> {
return id === undefined
? this.http.post<School>(this.base, body)
: this.http.put<School>(`${this.base}/${id}`, body);
: this.http.put<School>(`${this.base}/${id}`, body, this.options(id, revision));
}
saveDancer(schoolId: number, body: DancerRequest, id?: number): Observable<Dancer> {
return this.save(`${this.base}/${schoolId}/dancers`, body, id);
saveDancer(
schoolId: number,
body: DancerRequest,
id?: number,
revision?: number,
): Observable<Dancer> {
return this.save(`${this.base}/${schoolId}/dancers`, body, schoolId, id, revision);
}
saveGroup(schoolId: number, body: GroupRequest, id?: number): Observable<DanceGroup> {
return this.save(`${this.base}/${schoolId}/dance-groups`, body, id);
saveGroup(
schoolId: number,
body: GroupRequest,
id?: number,
revision?: number,
): Observable<DanceGroup> {
return this.save(`${this.base}/${schoolId}/dance-groups`, body, schoolId, id, revision);
}
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
return this.save(`${this.base}/${schoolId}/shows`, body, id);
saveShow(schoolId: number, body: ShowRequest, id?: number, revision?: number): Observable<Show> {
return this.save(`${this.base}/${schoolId}/shows`, body, schoolId, id, revision);
}
schedule(schoolId: number, showId: number): Observable<SchedulingRun> {
return this.http.post<SchedulingRun>(`${this.base}/${schoolId}/shows/${showId}/schedule`, null);
return this.http.post<SchedulingRun>(
`${this.base}/${schoolId}/shows/${showId}/schedule`,
null,
this.options(schoolId),
);
}
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
assignments,
});
saveAssignments(
schoolId: number,
showId: number,
assignments: number[][],
revision?: number,
): Observable<Show> {
return this.http.put<Show>(
`${this.base}/${schoolId}/shows/${showId}/assignments`,
{
assignments,
},
this.options(schoolId, revision),
);
}
finalize(schoolId: number, showId: number): Observable<Show> {
return this.http.post<Show>(`${this.base}/${schoolId}/shows/${showId}/finalize`, null);
finalize(schoolId: number, showId: number, revision?: number): Observable<Show> {
return this.http.post<Show>(
`${this.base}/${schoolId}/shows/${showId}/finalize`,
null,
this.options(schoolId, revision),
);
}
linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable<Coach> {
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId);
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, schoolId, coachId);
}
unlinkCoach(schoolId: number, coachId: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${schoolId}/coaches/${coachId}/dancer`);
return this.http.delete<void>(
`${this.base}/${schoolId}/coaches/${coachId}/dancer`,
this.options(schoolId),
);
}
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
delete(kind: ResourceKind, schoolId: number, id: number, revision?: number): Observable<void> {
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
kind
];
const url =
kind === 'school' ? `${this.base}/${id}` : `${this.base}/${schoolId}${collection}/${id}`;
return this.http.delete<void>(url);
return this.http.delete<void>(url, this.options(schoolId, revision));
}
membership(schoolId: number, groupId: number, dancerId: number, add: boolean): Observable<void> {
membership(
schoolId: number,
groupId: number,
dancerId: number,
add: boolean,
revision?: number,
): Observable<void> {
const url = `${this.base}/${schoolId}/dance-groups/${groupId}/dancers/${dancerId}`;
return add ? this.http.put<void>(url, null) : this.http.delete<void>(url);
return add
? this.http.put<void>(url, null, this.options(schoolId, revision))
: this.http.delete<void>(url, this.options(schoolId, revision));
}
private save<T>(url: string, body: unknown, id?: number): Observable<T> {
return id === undefined ? this.http.post<T>(url, body) : this.http.put<T>(`${url}/${id}`, body);
private save<T>(
url: string,
body: unknown,
schoolId: number,
id?: number,
revision?: number,
): Observable<T> {
return id === undefined
? this.http.post<T>(url, body, this.options(schoolId, revision))
: this.http.put<T>(`${url}/${id}`, body, this.options(schoolId, revision));
}
}

View File

@@ -477,6 +477,7 @@
<app-entity-editor
[target]="target"
[schoolId]="context.school.id"
[revision]="context.school.revision"
[shows]="context.shows"
(closed)="editor.set(null)"
(saved)="saved()"
@@ -487,6 +488,7 @@
[show]="show"
[groups]="context.groups"
[schoolId]="context.school.id"
[revision]="context.school.revision"
(closed)="scheduleEditing.set(null)"
(saved)="reload()"
/>
@@ -495,6 +497,7 @@
<app-group-members
[group]="group"
[schoolId]="context.school.id"
[revision]="context.school.revision"
[dancers]="context.dancers"
(closed)="members.set(null)"
(changed)="membershipChanged($event)"

View File

@@ -219,7 +219,10 @@ export class SchoolPage implements OnInit {
.join(', ') || 'Nog geen groep'
);
}
private deletionRevision?: number;
askDelete(target: DeleteTarget): void {
this.deletionRevision = this.data()?.school.revision;
this.deleteError.set('');
if (target.kind === 'group') {
const names =
@@ -255,7 +258,9 @@ export class SchoolPage implements OnInit {
this.deleteBusy.set(true);
this.deleteError.set('');
try {
await firstValueFrom(this.api.delete(target.kind, schoolId, target.id));
await firstValueFrom(
this.api.delete(target.kind, schoolId, target.id, this.deletionRevision),
);
this.deleting.set(null);
if (target.kind === 'school') await this.router.navigate(['/schools']);
else {

View File

@@ -50,11 +50,33 @@ describe('EntityEditor', () => {
const saved = vi.fn();
component.saved.subscribe(saved);
await component.save();
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined);
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined, undefined);
expect(saved).toHaveBeenCalledOnce();
expect(component.busy()).toBe(false);
});
it('retains the revision from opening the editor across background refreshes', async () => {
api.saveDancer.mockReturnValue(of({ id: 3 }));
const fixture = TestBed.createComponent(EntityEditor);
fixture.componentRef.setInput('target', {
kind: 'dancer',
value: { id: 3, name: 'Sam', birthDate: '2010-01-01' },
});
fixture.componentRef.setInput('schoolId', 7);
fixture.componentRef.setInput('revision', 4);
const component = fixture.componentInstance;
component.ngOnInit();
fixture.componentRef.setInput('revision', 5);
component.name = 'Sam edited';
await component.save();
expect(api.saveDancer).toHaveBeenCalledWith(
7,
{ name: 'Sam edited', birthDate: '2010-01-01' },
3,
4,
);
});
it('keeps the editor open and preserves input when saving fails', async () => {
api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
const component = editor({ kind: 'school' });
@@ -116,6 +138,7 @@ describe('EntityEditor', () => {
7,
expect.objectContaining({ preferredDayParts: 1 }),
undefined,
undefined,
);
});

View File

@@ -174,6 +174,8 @@ import { DialogFrame } from './dialog-frame';
})
export class EntityEditor implements OnInit {
readonly target = input.required<EditorTarget>();
readonly revision = input<number>();
private originalRevision?: number;
readonly schoolId = input<number>();
readonly shows = input<Show[]>([]);
readonly closed = output<void>();
@@ -207,6 +209,7 @@ export class EntityEditor implements OnInit {
return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`;
}
ngOnInit(): void {
this.originalRevision = this.revision();
const target = this.target();
if (!target.value) return;
if (target.kind === 'group') {
@@ -298,13 +301,14 @@ export class EntityEditor implements OnInit {
let request: Observable<unknown>;
switch (target.kind) {
case 'school':
request = this.api.saveSchool({ name: this.name.trim() }, id);
request = this.api.saveSchool({ name: this.name.trim() }, id, this.originalRevision);
break;
case 'dancer':
request = this.api.saveDancer(
schoolId!,
{ name: this.name.trim(), birthDate: this.date },
id,
this.originalRevision,
);
break;
case 'group':
@@ -318,6 +322,7 @@ export class EntityEditor implements OnInit {
styles: this.selectedStyles,
},
id,
this.originalRevision,
);
break;
case 'show':
@@ -329,6 +334,7 @@ export class EntityEditor implements OnInit {
preferredDayParts: this.preferredDayParts,
},
id,
this.originalRevision,
);
break;
}

View File

@@ -57,6 +57,8 @@ import { DialogFrame } from './dialog-frame';
`,
})
export class GroupMembers implements OnInit {
readonly revision = input<number>();
private originalRevision?: number;
readonly schoolId = input.required<number>();
readonly group = input.required<DanceGroup>();
readonly dancers = input.required<Dancer[]>();
@@ -70,6 +72,7 @@ export class GroupMembers implements OnInit {
);
private readonly api = inject(SchoolApi);
ngOnInit(): void {
this.originalRevision = this.revision();
this.memberIds.set([...this.group().dancerIds]);
}
async toggle(dancerId: number, event: Event): Promise<void> {
@@ -82,7 +85,10 @@ export class GroupMembers implements OnInit {
this.busy.set(true);
this.error.set('');
try {
await firstValueFrom(this.api.membership(this.schoolId(), this.group().id, dancerId, add));
await firstValueFrom(
this.api.membership(this.schoolId(), this.group().id, dancerId, add, this.originalRevision),
);
if (this.originalRevision !== undefined) this.originalRevision++;
this.memberIds.update((ids) =>
add ? [...ids, dancerId] : ids.filter((id) => id !== dancerId),
);

View File

@@ -57,7 +57,7 @@ describe('ScheduleEditor', () => {
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
api.saveAssignments.mockReturnValue(of(draft));
await component.save();
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]);
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]], undefined);
expect(component.current()).toEqual(draft);
expect(component.dirty()).toBe(false);
expect(api.finalize).not.toHaveBeenCalled();

View File

@@ -113,6 +113,8 @@ import { DialogFrame } from './dialog-frame';
export class ScheduleEditor implements OnInit {
readonly show = input.required<Show>();
readonly groups = input.required<DanceGroup[]>();
readonly revision = input<number>();
private originalRevision?: number;
readonly schoolId = input.required<number>();
readonly closed = output<void>();
readonly saved = output<void>();
@@ -124,6 +126,7 @@ export class ScheduleEditor implements OnInit {
assignments: number[][] = [];
ngOnInit(): void {
this.originalRevision = this.revision();
this.current.set(this.show());
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
}
@@ -162,9 +165,15 @@ export class ScheduleEditor implements OnInit {
try {
const result = await firstValueFrom(
finalize
? this.api.finalize(this.schoolId(), this.show().id)
: this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments),
? this.api.finalize(this.schoolId(), this.show().id, this.originalRevision)
: this.api.saveAssignments(
this.schoolId(),
this.show().id,
this.assignments,
this.originalRevision,
),
);
if (this.originalRevision !== undefined) this.originalRevision++;
this.current.set(result);
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
this.dirty.set(false);

View File

@@ -10,6 +10,7 @@ interface Preview {
shows: number;
existingId: number | null;
existingName: string | null;
existingRevision: number | null;
}
@Component({
selector: 'app-school-transfer',
@@ -117,7 +118,12 @@ export class SchoolTransfer {
const query = p.existingId === null ? '' : `?replaceId=${p.existingId}`;
await firstValueFrom(
this.http.post(`${this.base}/import${query}`, this.json, {
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(p.existingRevision === null
? {}
: { 'X-School-Revision': String(p.existingRevision) }),
},
}),
);
this.cancel();