feat(db): dispatches + member-number allocation + focus-tags parser
db.ts:
- User type gains pull_quote, member_number, focus_tags; all SELECT lists
updated. getAllCabMembers (member_number asc) and countCabMembers (used
by /pulse denominator) added.
- createUser allocates a member_number in-transaction when role=cab.
- updateUserRole returns { allocated: number | null } so admin can surface
the assignment; allocation is one-way: pilot→cab→pilot→cab keeps the
original number.
- allocateMemberNumber: MAX(member_number)+1, idempotent, never reuses.
- updateUserAdminFields: title / pull_quote / focus_tags (parsed array).
- createEvent / updateEvent extended for audience, duration_label,
action_label, notes_url.
- Dispatch CRUD: create / update / publish (stamps published_at) /
archive / delete. getDispatchById, getLatestPublishedDispatches,
getAllDispatchesForAdmin, getAdjacentDispatches (prev/next in published
order).
- getEventAttendees(slug, status) backs the upcoming-event avatar pile.
format.ts:
- AVATAR_PIGMENTS (terracotta/copper/walnut/indigo/heather) + pigmentForId
(id % palette, deterministic).
- parseFocusTags: trim, strip ASCII control chars (\x00-\x1F\x7F),
collapse internal whitespace, dedupe, cap 3 × 24.
- readFocusTags (safe JSON.parse for display).
- dispatchSlug / parseDispatchSlug: {id}-{kebab(title)}; renames don't
break links because the id leads.
- dispatchKindLabel, stripMarkdownLight, dispatchExcerptParas (two-paragraph
excerpt with sentence-boundary cut).
Tests: member-number allocation (idempotent, never reuses, allocates on
role transition) and focus_tags parser (control chars, whitespace collapse,
dedupe, cap). 24/24 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
865347f682
commit
368ce3ac8c
4 changed files with 325 additions and 15 deletions
236
src/lib/db.ts
236
src/lib/db.ts
|
|
@ -21,6 +21,9 @@ export interface User {
|
|||
title: string | null; // job title, e.g. 'VP Engineering'
|
||||
cab_joined_date: string | null; // ISO date; only set for role='cab'. Falls back to created_at when null.
|
||||
slug: string | null; // URL handle, kebab-case, unique. Auto-generated on createUser.
|
||||
pull_quote: string | null; // one sentence in the member's voice, rendered on /members
|
||||
member_number: number | null; // sequential, never reused. Allocated on role→cab transition.
|
||||
focus_tags: string | null; // JSON array of up to 3 strings (24 chars max each)
|
||||
}
|
||||
|
||||
export type UserPublic = Omit<User, 'password_hash'>;
|
||||
|
|
@ -100,7 +103,7 @@ export function getUserById(id: number): User | null {
|
|||
|
||||
export function getUserPublicById(id: number): UserPublic | null {
|
||||
return db.prepare(
|
||||
'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug FROM users WHERE id = ? AND active = 1'
|
||||
'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug,pull_quote,member_number,focus_tags FROM users WHERE id = ? AND active = 1'
|
||||
).get(id) as UserPublic | null;
|
||||
}
|
||||
|
||||
|
|
@ -113,10 +116,32 @@ export function createUser(data: {
|
|||
}): number {
|
||||
const slug = generateUniqueSlug(data.name);
|
||||
const cabJoined = data.role === 'cab' ? new Date().toISOString().slice(0, 10) : null;
|
||||
const tx = db.transaction(() => {
|
||||
const r = db.prepare(
|
||||
'INSERT INTO users (email,password_hash,name,organisation,role,slug,cab_joined_date) VALUES (?,?,?,?,?,?,?)'
|
||||
).run(data.email, data.password_hash, data.name, data.organisation, data.role, slug, cabJoined);
|
||||
return Number(r.lastInsertRowid);
|
||||
const id = Number(r.lastInsertRowid);
|
||||
if (data.role === 'cab') allocateMemberNumber(id);
|
||||
return id;
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve the next free member_number for a user. Strictly sequential,
|
||||
* never reused — even if a number-holder is deactivated and later replaced,
|
||||
* the new member gets MAX(member_number) + 1, not the retired number.
|
||||
* Idempotent: returns the existing number if already allocated.
|
||||
*/
|
||||
export function allocateMemberNumber(userId: number): number {
|
||||
return db.transaction(() => {
|
||||
const existing = db.prepare('SELECT member_number FROM users WHERE id = ?').get(userId) as { member_number: number | null } | undefined;
|
||||
if (existing?.member_number != null) return existing.member_number;
|
||||
const max = db.prepare('SELECT MAX(member_number) AS m FROM users').get() as { m: number | null };
|
||||
const next = (max.m ?? 0) + 1;
|
||||
db.prepare('UPDATE users SET member_number = ? WHERE id = ?').run(next, userId);
|
||||
return next;
|
||||
})();
|
||||
}
|
||||
|
||||
export function updateUserLastSeen(id: number): void {
|
||||
|
|
@ -127,8 +152,19 @@ export function updateUserProfile(id: number, name: string, bio: string): void {
|
|||
db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id);
|
||||
}
|
||||
|
||||
export function updateUserRole(id: number, role: Role): void {
|
||||
/** Returns the newly-allocated member_number when the transition lands on
|
||||
* cab and the user had none; null otherwise. Callers may ignore. */
|
||||
export function updateUserRole(id: number, role: Role): { allocated: number | null } {
|
||||
return db.transaction(() => {
|
||||
const current = db.prepare('SELECT role, member_number FROM users WHERE id = ?')
|
||||
.get(id) as { role: Role; member_number: number | null } | undefined;
|
||||
if (!current) return { allocated: null };
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||
if (role === 'cab' && current.member_number === null) {
|
||||
return { allocated: allocateMemberNumber(id) };
|
||||
}
|
||||
return { allocated: null };
|
||||
})();
|
||||
}
|
||||
|
||||
export function deactivateUser(id: number): void {
|
||||
|
|
@ -137,16 +173,41 @@ export function deactivateUser(id: number): void {
|
|||
|
||||
export function getAllUsersPublic(): UserPublic[] {
|
||||
return db.prepare(
|
||||
'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug FROM users ORDER BY organisation,name'
|
||||
'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug,pull_quote,member_number,focus_tags FROM users ORDER BY organisation,name'
|
||||
).all() as UserPublic[];
|
||||
}
|
||||
|
||||
export function getUserBySlug(slug: string): UserPublic | null {
|
||||
return db.prepare(
|
||||
'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug FROM users WHERE slug = ? AND active = 1'
|
||||
'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug,pull_quote,member_number,focus_tags FROM users WHERE slug = ? AND active = 1'
|
||||
).get(slug) as UserPublic | null;
|
||||
}
|
||||
|
||||
/** All cab users, ordered by member_number asc (with NULLs at the end as a defensive fallback). */
|
||||
export function getAllCabMembers(): UserPublic[] {
|
||||
return db.prepare(`
|
||||
SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug,pull_quote,member_number,focus_tags
|
||||
FROM users
|
||||
WHERE role = 'cab' AND active = 1
|
||||
ORDER BY (member_number IS NULL), member_number ASC, id ASC
|
||||
`).all() as UserPublic[];
|
||||
}
|
||||
|
||||
export function countCabMembers(): number {
|
||||
const r = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'cab' AND active = 1").get() as { n: number };
|
||||
return r.n;
|
||||
}
|
||||
|
||||
/** Update admin-editable profile fields (title, pull_quote, focus_tags). focus_tags is a parsed array. */
|
||||
export function updateUserAdminFields(id: number, data: {
|
||||
title: string | null;
|
||||
pull_quote: string | null;
|
||||
focus_tags: string[];
|
||||
}): void {
|
||||
db.prepare('UPDATE users SET title = ?, pull_quote = ?, focus_tags = ? WHERE id = ?')
|
||||
.run(data.title, data.pull_quote, JSON.stringify(data.focus_tags), id);
|
||||
}
|
||||
|
||||
// ── Slugs ────────────────────────────────────────────────────────
|
||||
|
||||
export function slugifyName(name: string): string {
|
||||
|
|
@ -705,7 +766,7 @@ export function setRoadmapAttributions(itemId: number, userIds: number[]): void
|
|||
|
||||
// ── Events ───────────────────────────────────────────────────────
|
||||
|
||||
export type EventKind = 'dinner' | 'office_hours' | 'summit' | 'virtual';
|
||||
export type EventKind = 'dinner' | 'office_hours' | 'summit' | 'virtual' | 'working_session';
|
||||
|
||||
export interface Event {
|
||||
id: number;
|
||||
|
|
@ -718,6 +779,10 @@ export interface Event {
|
|||
ends_at: string | null;
|
||||
capacity: number | null;
|
||||
photo_url: string | null;
|
||||
audience: string | null;
|
||||
duration_label: string | null;
|
||||
action_label: string | null;
|
||||
notes_url: string | null;
|
||||
created_at: string;
|
||||
created_by: number | null;
|
||||
}
|
||||
|
|
@ -732,14 +797,20 @@ export function createEvent(data: {
|
|||
ends_at: string | null;
|
||||
capacity: number | null;
|
||||
photo_url: string | null;
|
||||
audience?: string | null;
|
||||
duration_label?: string | null;
|
||||
action_label?: string | null;
|
||||
notes_url?: string | null;
|
||||
created_by: number;
|
||||
}): number {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, audience, duration_label, action_label, notes_url, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
data.slug, data.title, data.kind, data.description, data.location,
|
||||
data.starts_at, data.ends_at, data.capacity, data.photo_url, data.created_by,
|
||||
data.starts_at, data.ends_at, data.capacity, data.photo_url,
|
||||
data.audience ?? null, data.duration_label ?? null, data.action_label ?? null, data.notes_url ?? null,
|
||||
data.created_by,
|
||||
);
|
||||
return Number(r.lastInsertRowid);
|
||||
}
|
||||
|
|
@ -753,14 +824,21 @@ export function updateEvent(id: number, data: {
|
|||
ends_at: string | null;
|
||||
capacity: number | null;
|
||||
photo_url: string | null;
|
||||
audience?: string | null;
|
||||
duration_label?: string | null;
|
||||
action_label?: string | null;
|
||||
notes_url?: string | null;
|
||||
}): void {
|
||||
db.prepare(`
|
||||
UPDATE events SET title = ?, kind = ?, description = ?, location = ?,
|
||||
starts_at = ?, ends_at = ?, capacity = ?, photo_url = ?
|
||||
starts_at = ?, ends_at = ?, capacity = ?, photo_url = ?,
|
||||
audience = ?, duration_label = ?, action_label = ?, notes_url = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
data.title, data.kind, data.description, data.location,
|
||||
data.starts_at, data.ends_at, data.capacity, data.photo_url, id,
|
||||
data.starts_at, data.ends_at, data.capacity, data.photo_url,
|
||||
data.audience ?? null, data.duration_label ?? null, data.action_label ?? null, data.notes_url ?? null,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -909,3 +987,139 @@ export function countShippedAttributions(userId: number): number {
|
|||
`).get(userId) as { n: number };
|
||||
return r.n;
|
||||
}
|
||||
|
||||
// ── Dispatches ───────────────────────────────────────────────────
|
||||
|
||||
export type DispatchKind = 'decision' | 'update' | 'behind_the_scenes' | 'note';
|
||||
export type DispatchStatus = 'draft' | 'published' | 'archived';
|
||||
|
||||
export interface Dispatch {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
excerpt: string | null;
|
||||
kind: DispatchKind;
|
||||
author_id: number;
|
||||
status: DispatchStatus;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DispatchWithAuthor extends Dispatch {
|
||||
author_name: string;
|
||||
author_title: string | null;
|
||||
author_role: Role;
|
||||
}
|
||||
|
||||
export function createDispatch(data: {
|
||||
title: string;
|
||||
body: string;
|
||||
excerpt: string | null;
|
||||
kind: DispatchKind;
|
||||
author_id: number;
|
||||
status: DispatchStatus;
|
||||
}): number {
|
||||
const published_at = data.status === 'published'
|
||||
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
: null;
|
||||
const r = db.prepare(`
|
||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
|
||||
return Number(r.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function updateDispatch(id: number, data: {
|
||||
title: string;
|
||||
body: string;
|
||||
excerpt: string | null;
|
||||
kind: DispatchKind;
|
||||
author_id: number;
|
||||
}): void {
|
||||
db.prepare(`
|
||||
UPDATE dispatches
|
||||
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id);
|
||||
}
|
||||
|
||||
/** Promote draft → published, stamping published_at = now() on first publish.
|
||||
* Idempotent: if already published, published_at is preserved. */
|
||||
export function publishDispatch(id: number): void {
|
||||
db.prepare(`
|
||||
UPDATE dispatches
|
||||
SET status = 'published',
|
||||
published_at = COALESCE(published_at, datetime('now')),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(id);
|
||||
}
|
||||
|
||||
/** Archive a dispatch. Leaves published_at intact for history. */
|
||||
export function archiveDispatch(id: number): void {
|
||||
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function deleteDispatch(id: number): void {
|
||||
db.prepare('DELETE FROM dispatches WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function getDispatchById(id: number): DispatchWithAuthor | null {
|
||||
return db.prepare(`
|
||||
SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role
|
||||
FROM dispatches d JOIN users u ON u.id = d.author_id
|
||||
WHERE d.id = ?
|
||||
`).get(id) as DispatchWithAuthor | null;
|
||||
}
|
||||
|
||||
export function getLatestPublishedDispatches(limit: number): DispatchWithAuthor[] {
|
||||
return db.prepare(`
|
||||
SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role
|
||||
FROM dispatches d JOIN users u ON u.id = d.author_id
|
||||
WHERE d.status = 'published'
|
||||
ORDER BY d.published_at DESC, d.id DESC
|
||||
LIMIT ?
|
||||
`).all(limit) as DispatchWithAuthor[];
|
||||
}
|
||||
|
||||
export function getAllDispatchesForAdmin(opts: { status?: DispatchStatus } = {}): DispatchWithAuthor[] {
|
||||
const clause = opts.status ? `WHERE d.status = '${opts.status}'` : '';
|
||||
return db.prepare(`
|
||||
SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role
|
||||
FROM dispatches d JOIN users u ON u.id = d.author_id
|
||||
${clause}
|
||||
ORDER BY d.created_at DESC, d.id DESC
|
||||
`).all() as DispatchWithAuthor[];
|
||||
}
|
||||
|
||||
/** Adjacent dispatches in published order. Returns the immediately newer and older. */
|
||||
export function getAdjacentDispatches(id: number): { prev: DispatchWithAuthor | null; next: DispatchWithAuthor | null } {
|
||||
const cur = getDispatchById(id);
|
||||
if (!cur || cur.status !== 'published' || !cur.published_at) return { prev: null, next: null };
|
||||
const prev = db.prepare(`
|
||||
SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role
|
||||
FROM dispatches d JOIN users u ON u.id = d.author_id
|
||||
WHERE d.status = 'published' AND d.published_at < ?
|
||||
ORDER BY d.published_at DESC LIMIT 1
|
||||
`).get(cur.published_at) as DispatchWithAuthor | null;
|
||||
const next = db.prepare(`
|
||||
SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role
|
||||
FROM dispatches d JOIN users u ON u.id = d.author_id
|
||||
WHERE d.status = 'published' AND d.published_at > ?
|
||||
ORDER BY d.published_at ASC LIMIT 1
|
||||
`).get(cur.published_at) as DispatchWithAuthor | null;
|
||||
return { prev, next };
|
||||
}
|
||||
|
||||
// ── Event attendees (for the avatar pile on /events) ─────────────
|
||||
|
||||
export function getEventAttendees(slug: string, status: 'yes' | 'interested' | 'no' = 'yes'): UserPublic[] {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.email, u.name, u.organisation, u.role, u.bio, u.created_at, u.last_seen_at, u.active,
|
||||
u.title, u.cab_joined_date, u.slug, u.pull_quote, u.member_number, u.focus_tags
|
||||
FROM attendance a JOIN users u ON u.id = a.user_id
|
||||
WHERE a.meeting_slug = ? AND a.kind = 'event' AND a.status = ?
|
||||
ORDER BY a.updated_at DESC
|
||||
`).all(slug, status) as UserPublic[];
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
35
tests/focus-tags.test.ts
Normal file
35
tests/focus-tags.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFocusTags } from '../src/lib/format.js';
|
||||
|
||||
describe('parseFocusTags', () => {
|
||||
it('trims, splits on commas, drops empties', () => {
|
||||
expect(parseFocusTags(' GDPR, Telemetry, , Policy ')).toEqual(['GDPR', 'Telemetry', 'Policy']);
|
||||
});
|
||||
|
||||
it('caps at 3 entries', () => {
|
||||
expect(parseFocusTags('a,b,c,d,e')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('caps each entry at 24 chars', () => {
|
||||
expect(parseFocusTags('a-very-long-tag-that-runs-on-forever')).toEqual(['a-very-long-tag-that-run']);
|
||||
});
|
||||
|
||||
it('dedupes after normalisation', () => {
|
||||
expect(parseFocusTags('GDPR, gdpr, GDPR')).toEqual(['GDPR', 'gdpr']); // case-sensitive
|
||||
expect(parseFocusTags('Foo, Foo, Foo')).toEqual(['Foo']);
|
||||
});
|
||||
|
||||
it('collapses internal whitespace so visually-identical tags merge', () => {
|
||||
expect(parseFocusTags('Healthcare data, Healthcare data')).toEqual(['Healthcare data']);
|
||||
});
|
||||
|
||||
it('strips ASCII control characters', () => {
|
||||
expect(parseFocusTags('Policy, Telemetry')).toEqual(['Policy', 'Telemetry']);
|
||||
});
|
||||
|
||||
it('returns [] on empty input', () => {
|
||||
expect(parseFocusTags('')).toEqual([]);
|
||||
expect(parseFocusTags(' ')).toEqual([]);
|
||||
expect(parseFocusTags(',,,')).toEqual([]);
|
||||
});
|
||||
});
|
||||
61
tests/member-number.test.ts
Normal file
61
tests/member-number.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { allocateMemberNumber, createUser, updateUserRole, deactivateUser } from '../src/lib/db.js';
|
||||
|
||||
const db = (globalThis as typeof globalThis & { __bifrost_db?: Database.Database }).__bifrost_db!;
|
||||
|
||||
function makeUser(name: string, role: 'pilot' | 'cab' | 'fenja' = 'pilot'): number {
|
||||
return createUser({ email: `${name}@x.test`, password_hash: 'x', name, organisation: 'TestOrg', role });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.exec(`
|
||||
DELETE FROM votes;
|
||||
DELETE FROM pulses;
|
||||
DELETE FROM activity;
|
||||
DELETE FROM dispatches;
|
||||
DELETE FROM users;
|
||||
`);
|
||||
});
|
||||
|
||||
describe('allocateMemberNumber', () => {
|
||||
it('allocates 1 for the first cab user and sequential numbers thereafter', () => {
|
||||
const a = makeUser('alice', 'cab');
|
||||
const b = makeUser('bob', 'cab');
|
||||
const c = makeUser('cara', 'cab');
|
||||
expect(db.prepare('SELECT member_number FROM users WHERE id=?').get(a)).toEqual({ member_number: 1 });
|
||||
expect(db.prepare('SELECT member_number FROM users WHERE id=?').get(b)).toEqual({ member_number: 2 });
|
||||
expect(db.prepare('SELECT member_number FROM users WHERE id=?').get(c)).toEqual({ member_number: 3 });
|
||||
});
|
||||
|
||||
it('is idempotent — calling allocate on a user who already has a number returns the same one', () => {
|
||||
const a = makeUser('alice', 'cab');
|
||||
const first = (db.prepare('SELECT member_number AS n FROM users WHERE id=?').get(a) as { n: number }).n;
|
||||
const reAllocated = allocateMemberNumber(a);
|
||||
expect(reAllocated).toBe(first);
|
||||
});
|
||||
|
||||
it('never reuses: deactivating a holder does not free their number', () => {
|
||||
const a = makeUser('alice', 'cab'); // #1
|
||||
const b = makeUser('bob', 'cab'); // #2
|
||||
deactivateUser(a);
|
||||
const c = makeUser('cara', 'cab'); // expects #3, not #1
|
||||
expect((db.prepare('SELECT member_number AS n FROM users WHERE id=?').get(c) as { n: number }).n).toBe(3);
|
||||
});
|
||||
|
||||
it('does not allocate when a non-cab user is created', () => {
|
||||
const a = makeUser('alice', 'pilot');
|
||||
expect(db.prepare('SELECT member_number FROM users WHERE id=?').get(a)).toEqual({ member_number: null });
|
||||
});
|
||||
|
||||
it('allocates on role transition pilot → cab and only the first time', () => {
|
||||
const a = makeUser('alice', 'pilot');
|
||||
const r1 = updateUserRole(a, 'cab');
|
||||
expect(r1.allocated).toBe(1);
|
||||
// transition back and forth must not change the number
|
||||
updateUserRole(a, 'pilot');
|
||||
const r2 = updateUserRole(a, 'cab');
|
||||
expect(r2.allocated).toBeNull();
|
||||
expect((db.prepare('SELECT member_number AS n FROM users WHERE id=?').get(a) as { n: number }).n).toBe(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue