* fix(guests): surface duplicate guest registrations, and stop making so many (#1210) Guest registration always inserts. A client whose token expired — or who opens the gallery on a second device — becomes a new gallery_guests row, and their likes and favourites split across the copies. The photographer's 'final selection' is then only trustworthy if somebody notices two Tinas with half the picks each. Two halves, neither of which touches the registration path. **Say which rows are the same person.** Merging already worked, endpoint and UI both; nothing said WHICH rows to merge. The guests list now marks each row with the others sharing its email and returns a count for the banner, and the admin list offers the group straight to the merge mode that already exists. Case-folded and trimmed, because the same person types Tina@ one day and tina@ the next and both read as distinct rows. Email only — two guests called Anna are not evidence of anything, and rows without an email are not grouped at all since require_name_email is off by default and a shared link produces plenty of them. It preselects rather than merges: which row survives decides the name and verification state the merged guest keeps, and that is the admin's call. **Create fewer of them.** The guest token was 24h and every call site took that default, so even the same browser lost its identity after a day of inactivity. Now 30 days, GUEST_TOKEN_TTL to override. A guest token is scoped to one event, carries no admin capability, and the gallery is already behind whatever protects it — 30 days is the shape of a real proofing cycle. Deliberately NOT done: reusing a guest row when a typed email matches, which the report suggests first. It would let anyone who knows an address inherit that person's identity and selections, and answering differently for a known email would leak which addresses are in the gallery — the thing /guest/recover already goes out of its way to avoid. Prevention at the entry path needs the verification round-trip, which is a separate decision about friction. 13 tests; 8 of the 9 backend ones fail without the change. The frontend ones caught a real bug while being written — the new useMemo sat after the loading early-return, so the hook count changed between renders. * fix(guests): merge must not strand a pending invite (#1210) Three findings from external review of #1216. **A merge could kill an emailed invite link.** Creating an invite inserts a real gallery_guests row, so an admin who pre-mints one and then sees the guest self-register has two rows sharing an email — which this feature now points out and offers to merge. Redemption resolves guest_invites.guest_id with is_deleted: false, so merging soft-deleted the row the link pointed at: the client got 404 guest_missing while the invite dialog still showed the invite as Pending. Nothing anywhere said the link was dead. Unredeemed, unrevoked invites now move to the survivor first. Spent ones stay put — a redeemed invite records who redeemed what, and retargeting it would rewrite that. **The preselection silently chose the survivor.** performMerge keeps mergeSelection[0], and the group was handed over in API order, which is newest-first — so Review then Merge discarded an older, email-verified row holding most of the picks in favour of a fresh re-registration. The proposal is now ordered deliberately: verified first, then whoever holds the most feedback, then the oldest. Still only a proposal, and the confirmation now names the survivor by email as well as name, because duplicates share a name and 'Merge 2 guests into Tina?' said nothing. **duplicate_of was quadratic.** Every row carried the other n-1 ids, so a group of n serialised n² of them — and nothing consumed the list: the UI asked only whether a row was in a group, then regrouped by email itself. Replaced with duplicate_group, the normalised email, which keeps the payload linear and the case/whitespace folding in one place instead of reimplemented on the client. Two new backend tests for the invite paths, one frontend test asserting the merge call keeps the verified row. The invite test fails against the un-fixed code. * fix(guests): keep guest-controlled input out of who survives a merge (#1210) Round 2 of external review on #1216. **The survivor ranking used an attacker-controlled signal.** Preferring whoever holds the most feedback looked like the obvious tiebreak and is exactly the wrong one: registration does not verify the address, so anyone who knows a guest's email can register with it, mark enough photos to out-rank the real person, and be preselected as the survivor. An admin accepting a confirmation between two rows with the same name and email would then move the victim's picks onto an identity whose token the visitor still holds. distinct_photos is guest-controlled and has no business deciding this. The ranking is now email_verified_at then created_at — both server-set. **A merge could make the survivor unrecoverable.** Rows are grouped with case and whitespace folded out, so a merge can be proposed between [email protected] and [email protected]. /guest/recover lowercases what the guest types and then matches on equality, so a survivor left holding the raw value can never be recovered by email again. The kept row's address is now canonicalised during the merge. Both write paths normalise today, so this covers rows that predate that — which are exactly the rows case-folded grouping surfaces. Two more backend tests. The residual, stated plainly: an admin can still merge two unverified rows in either order. What is gone is the tool ranking them by something a visitor controls. * fix(compose): pass GUEST_TOKEN_TTL through to the backend (#1210) The override was documented in .env.example and could never take effect: the backend service takes an explicit environment list, so a variable not named there never reaches the container. An operator following the documentation would have shortened the guest session and seen nothing change. docker-compose.production.yml uses env_file: .env and already passed it through; docker-compose.dev.yml is gitignored, so only this file needs it. * fix(guests): the admin picks the merge survivor, the tool does not (#1210) Fourth review round on the same point, and the right conclusion is that there is no correct automatic answer. Every rule tried was wrong somewhere. Most-feedback is guest-controlled — the address is never verified at registration, so anyone who knows it can register and mark photos until they out-rank the real person. Oldest-first, the replacement, is worse for the ordinary case: when a token expires the OLD row is the dead identity and the new one is the visitor's live session, so keeping the oldest deletes the identity they are actually using, and the frontend holds that deleted guest in sessionStorage without clearing it on a 401. Registration timing is visitor-controlled too. The data does not say which row is really the person. So the UI asks: merge mode gains a Keep column, the button stays disabled until a row is nominated, and only rows included in the merge can be nominated. The group is still preselected — finding the duplicates was always the point — but nothing about who survives is decided by sort order any more. This also makes the claim in the PR description true. It said the admin decides which row survives; until now the preselection quietly decided it for them. Two rewritten frontend tests: the merge is blocked until a survivor is chosen and then keeps exactly that row, and a row outside the group cannot be nominated. The test i18n mock now interpolates, so aria-labels are queryable by their rendered text. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
27aff7c04e
commit
5c85e0c0e4
@@ -10,6 +10,13 @@ NODE_ENV=production
|
|||||||
# Generate one with: openssl rand -base64 64
|
# Generate one with: openssl rand -base64 64
|
||||||
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||||
|
|
||||||
|
# How long a gallery guest stays recognised (#1210). Default 30d. It was 24h,
|
||||||
|
# which meant a client reviewing a gallery across two weekends registered again
|
||||||
|
# in between — and each re-registration is a separate guest whose likes and
|
||||||
|
# favourites no longer join up with the first visit's. Takes any jsonwebtoken
|
||||||
|
# duration ('7d', '12h'); shorten it if your galleries hold sensitive work.
|
||||||
|
#GUEST_TOKEN_TTL=30d
|
||||||
|
|
||||||
# OIDC SSO for admins (#798) — configured in the admin UI; only these two
|
# OIDC SSO for admins (#798) — configured in the admin UI; only these two
|
||||||
# values live in the environment:
|
# values live in the environment:
|
||||||
# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET).
|
# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET).
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* Spotting one person registered twice (#1210).
|
||||||
|
*
|
||||||
|
* Guest registration always inserts. A client whose token expired, or who
|
||||||
|
* opens the gallery on a second device, becomes a new gallery_guests row and
|
||||||
|
* their likes and favourites split across the copies — so the photographer's
|
||||||
|
* "final selection" is only trustworthy after someone notices two Tinas with
|
||||||
|
* half the picks each and merges them.
|
||||||
|
*
|
||||||
|
* Merging already worked. This is the half that was missing: saying which rows
|
||||||
|
* are the same person, so the admin does not have to find them by eye.
|
||||||
|
*
|
||||||
|
* Detection only — the registration path is deliberately untouched. Reusing a
|
||||||
|
* row because someone typed a matching email would let anyone who knows that
|
||||||
|
* email inherit the identity and its selections, and answering differently for
|
||||||
|
* a known email would leak which addresses are in the gallery, which is
|
||||||
|
* exactly what /guest/recover goes out of its way to avoid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||||
|
|
||||||
|
describe('duplicate guest detection (#1210)', () => {
|
||||||
|
let db; let cleanup; let app; let eventId;
|
||||||
|
|
||||||
|
const listGuests = async () => {
|
||||||
|
const res = await request(app).get(`/api/admin/events/${eventId}/guests`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGuest = async (name, email, extra = {}) => {
|
||||||
|
const [g] = await db('gallery_guests').insert({
|
||||||
|
event_id: eventId, name, email,
|
||||||
|
identifier: `id-${name}-${Math.random()}`,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
last_seen_at: new Date().toISOString(),
|
||||||
|
is_deleted: false,
|
||||||
|
...extra,
|
||||||
|
}).returning('id');
|
||||||
|
return typeof g === 'object' ? g.id : g;
|
||||||
|
};
|
||||||
|
|
||||||
|
const byId = (body, id) => body.guests.find((g) => g.id === id);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.doMock('../../src/middleware/auth', () => ({
|
||||||
|
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||||
|
}));
|
||||||
|
jest.doMock('../../src/middleware/permissions', () => ({
|
||||||
|
requirePermission: () => (_req, _res, next) => next(),
|
||||||
|
}));
|
||||||
|
jest.doMock('../../src/middleware/ownership', () => ({
|
||||||
|
requireEventOwnership: (_req, _res, next) => next(),
|
||||||
|
}));
|
||||||
|
jest.doMock('../../src/utils/logger', () => ({
|
||||||
|
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
await seedMinimal(db);
|
||||||
|
|
||||||
|
const [ev] = await db('events').insert({
|
||||||
|
slug: 'dupe-guests', event_type: 'wedding', event_name: 'Dupe Guests',
|
||||||
|
event_date: '2026-08-01', host_email: '[email protected]', admin_email: '[email protected]',
|
||||||
|
password_hash: 'x', share_link: '/gallery/dupe-guests/share',
|
||||||
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||||
|
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin', require('../../src/routes/adminGuests'));
|
||||||
|
}, 180000);
|
||||||
|
|
||||||
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db('gallery_guests').where({ event_id: eventId }).del();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups duplicates under a shared key', async () => {
|
||||||
|
const first = await addGuest('Tina', '[email protected]');
|
||||||
|
const second = await addGuest('Tina', '[email protected]');
|
||||||
|
const other = await addGuest('Marc', '[email protected]');
|
||||||
|
|
||||||
|
const body = await listGuests();
|
||||||
|
|
||||||
|
// A shared group key rather than a list of sibling ids: the payload stays
|
||||||
|
// linear in the number of guests, and the case/whitespace folding lives in
|
||||||
|
// one place instead of being reimplemented on the client.
|
||||||
|
expect(byId(body, first).duplicate_group).toBe('[email protected]');
|
||||||
|
expect(byId(body, second).duplicate_group).toBe('[email protected]');
|
||||||
|
expect(byId(body, other).duplicate_group).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the groups and the rows in them', async () => {
|
||||||
|
await addGuest('Tina', '[email protected]');
|
||||||
|
await addGuest('Tina', '[email protected]');
|
||||||
|
await addGuest('Ben', '[email protected]');
|
||||||
|
await addGuest('Ben', '[email protected]');
|
||||||
|
await addGuest('Ben', '[email protected]');
|
||||||
|
await addGuest('Marc', '[email protected]');
|
||||||
|
|
||||||
|
expect((await listGuests()).duplicates).toEqual({ groups: 2, guests: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the same address typed with different capitals or a stray space', async () => {
|
||||||
|
// The same person on a different day. Both read as distinct rows in the
|
||||||
|
// admin list, which is precisely why they need catching here.
|
||||||
|
const a = await addGuest('Tina', '[email protected]');
|
||||||
|
const b = await addGuest('Tina', '[email protected] ');
|
||||||
|
|
||||||
|
const body = await listGuests();
|
||||||
|
expect(byId(body, a).duplicate_group).toBe('[email protected]');
|
||||||
|
expect(byId(body, b).duplicate_group).toBe('[email protected]');
|
||||||
|
expect(body.duplicates).toEqual({ groups: 1, guests: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat two guests without an email as the same person', async () => {
|
||||||
|
// require_name_email is off by default, so a shared gallery link produces
|
||||||
|
// plenty of these. Grouping them would merge strangers.
|
||||||
|
const a = await addGuest('Anon', null);
|
||||||
|
const b = await addGuest('Anon', null);
|
||||||
|
|
||||||
|
const body = await listGuests();
|
||||||
|
expect(byId(body, a).duplicate_group).toBeNull();
|
||||||
|
expect(byId(body, b).duplicate_group).toBeNull();
|
||||||
|
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a shared name as evidence of anything', async () => {
|
||||||
|
const a = await addGuest('Anna', '[email protected]');
|
||||||
|
const b = await addGuest('Anna', '[email protected]');
|
||||||
|
|
||||||
|
const body = await listGuests();
|
||||||
|
expect(byId(body, a).duplicate_group).toBeNull();
|
||||||
|
expect(byId(body, b).duplicate_group).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a pending invite to the survivor instead of stranding it', async () => {
|
||||||
|
// Creating an invite inserts a real gallery_guests row, so an admin who
|
||||||
|
// pre-mints one and then sees the guest self-register has two rows — and
|
||||||
|
// this feature now points that pair out and offers the merge. Redemption
|
||||||
|
// resolves guest_invites.guest_id with `is_deleted: false`, so merging
|
||||||
|
// without moving the invite leaves the emailed link returning 404
|
||||||
|
// guest_missing while the invite dialog still shows it as Pending.
|
||||||
|
const placeholder = await addGuest('Tina', '[email protected]');
|
||||||
|
const selfRegistered = await addGuest('Tina', '[email protected]');
|
||||||
|
const [inv] = await db('guest_invites').insert({
|
||||||
|
event_id: eventId, guest_id: placeholder, token: 'invite-token-1',
|
||||||
|
created_by_admin_id: 1, created_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
const inviteId = typeof inv === 'object' ? inv.id : inv;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/admin/events/${eventId}/guests/${selfRegistered}/merge`)
|
||||||
|
.send({ mergeIds: [placeholder] });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const invite = await db('guest_invites').where({ id: inviteId }).first();
|
||||||
|
expect(invite.guest_id).toBe(selfRegistered);
|
||||||
|
// And it still resolves: the survivor is not soft-deleted.
|
||||||
|
const target = await db('gallery_guests').where({ id: invite.guest_id }).first();
|
||||||
|
expect(Boolean(target.is_deleted)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a spent invite pointing at what it actually redeemed', async () => {
|
||||||
|
// A redeemed invite is a record of who redeemed what. Retargeting it would
|
||||||
|
// rewrite that history to name a guest who was never on the other end.
|
||||||
|
const old = await addGuest('Tina', '[email protected]');
|
||||||
|
const kept = await addGuest('Tina', '[email protected]');
|
||||||
|
const [inv] = await db('guest_invites').insert({
|
||||||
|
event_id: eventId, guest_id: old, token: 'invite-token-2',
|
||||||
|
created_by_admin_id: 1, redeemed_at: new Date().toISOString(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
const inviteId = typeof inv === 'object' ? inv.id : inv;
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
|
||||||
|
.send({ mergeIds: [old] });
|
||||||
|
|
||||||
|
expect((await db('guest_invites').where({ id: inviteId }).first()).guest_id).toBe(old);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalises the survivor\'s address so recovery can still find them', async () => {
|
||||||
|
// Grouping folds case and whitespace, so a merge can be proposed between a
|
||||||
|
// clean address and a legacy one that is not. /guest/recover lowercases
|
||||||
|
// what the guest types and matches on equality, so a survivor left holding
|
||||||
|
// the raw value becomes permanently unrecoverable by email.
|
||||||
|
const legacy = await addGuest('Tina', '[email protected] ');
|
||||||
|
const other = await addGuest('Tina', '[email protected]');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/admin/events/${eventId}/guests/${legacy}/merge`)
|
||||||
|
.send({ mergeIds: [other] });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
expect((await db('gallery_guests').where({ id: legacy }).first()).email).toBe('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an already-canonical survivor untouched', async () => {
|
||||||
|
const kept = await addGuest('Tina', '[email protected]');
|
||||||
|
const dupe = await addGuest('Tina', '[email protected]');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
|
||||||
|
.send({ mergeIds: [dupe] });
|
||||||
|
|
||||||
|
expect((await db('gallery_guests').where({ id: kept }).first()).email).toBe('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a removed guest', async () => {
|
||||||
|
const kept = await addGuest('Tina', '[email protected]');
|
||||||
|
await addGuest('Tina', '[email protected]', { is_deleted: true });
|
||||||
|
|
||||||
|
const body = await listGuests();
|
||||||
|
// The deleted row is not listed at all, so the survivor is not a duplicate
|
||||||
|
// of something the admin cannot see or merge.
|
||||||
|
expect(body.guests.map((g) => g.id)).toEqual([kept]);
|
||||||
|
expect(byId(body, kept).duplicate_group).toBeNull();
|
||||||
|
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* How long a guest stays recognised (#1210).
|
||||||
|
*
|
||||||
|
* The expiry was 24h and every call site took the default, so a client
|
||||||
|
* reviewing a gallery across two weekends lost their identity in between and
|
||||||
|
* registered again — and each re-registration is a fresh gallery_guests row
|
||||||
|
* whose likes and favourites no longer join up with the first visit's.
|
||||||
|
*
|
||||||
|
* Pinned as a test because the value is the whole fix: a silent revert to 24h
|
||||||
|
* would restore the duplicate churn without breaking anything visible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
describe('guest token lifetime (#1210)', () => {
|
||||||
|
const load = (ttl) => {
|
||||||
|
jest.resetModules();
|
||||||
|
process.env.JWT_SECRET = 'guest-ttl-secret';
|
||||||
|
if (ttl === undefined) delete process.env.GUEST_TOKEN_TTL;
|
||||||
|
else process.env.GUEST_TOKEN_TTL = ttl;
|
||||||
|
return require('../../src/middleware/guestAuth');
|
||||||
|
};
|
||||||
|
|
||||||
|
const lifetimeDays = (token) => {
|
||||||
|
const { iat, exp } = jwt.decode(token);
|
||||||
|
return Math.round((exp - iat) / 86400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sign = (mod) => mod.signGuestToken({
|
||||||
|
guestId: 1, eventId: 2, identifier: 'abc', name: 'Tina',
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => { delete process.env.GUEST_TOKEN_TTL; });
|
||||||
|
|
||||||
|
it('defaults to 30 days, not 24 hours', () => {
|
||||||
|
expect(lifetimeDays(sign(load(undefined)))).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours an operator override', () => {
|
||||||
|
// The value goes straight to jsonwebtoken, so anything it accepts works;
|
||||||
|
// an operator who wants the old behaviour back can have it.
|
||||||
|
expect(lifetimeDays(sign(load('7d')))).toBe(7);
|
||||||
|
expect(lifetimeDays(sign(load('24h')))).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still signs a token the guest middleware accepts', () => {
|
||||||
|
const mod = load(undefined);
|
||||||
|
const decoded = jwt.verify(sign(mod), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||||
|
expect(decoded).toMatchObject({ type: 'guest', guestId: 1, eventId: 2, identifier: 'abc' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,10 +79,26 @@ function requireGuest(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign a new guest JWT. Scoped to a specific event and guest row.
|
* How long a guest stays recognised (#1210).
|
||||||
* Expiry matches the gallery token default (24h).
|
*
|
||||||
|
* Was 24h, matching the gallery token, and every call site took that default —
|
||||||
|
* so a client reviewing a gallery across two weekends lost their identity in
|
||||||
|
* between and registered again. Each of those re-registrations is a fresh
|
||||||
|
* gallery_guests row, and their likes and favourites scatter across the
|
||||||
|
* duplicates until an admin merges them, which is the harm reported in #1210.
|
||||||
|
*
|
||||||
|
* A guest token is a weaker thing than an admin session: it is scoped to one
|
||||||
|
* event, carries no admin capability, and the gallery itself is already behind
|
||||||
|
* whatever password or share link protects it. 30 days is the shape of a real
|
||||||
|
* proofing cycle. Configurable for operators who want it shorter — the value
|
||||||
|
* goes straight to jsonwebtoken, so it takes any zeit/ms string ('7d', '12h').
|
||||||
*/
|
*/
|
||||||
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') {
|
const GUEST_TOKEN_TTL = process.env.GUEST_TOKEN_TTL || '30d';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a new guest JWT. Scoped to a specific event and guest row.
|
||||||
|
*/
|
||||||
|
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = GUEST_TOKEN_TTL) {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{
|
{
|
||||||
type: 'guest',
|
type: 'guest',
|
||||||
|
|||||||
@@ -88,19 +88,64 @@ router.get(
|
|||||||
)
|
)
|
||||||
.orderBy('gallery_guests.created_at', 'desc');
|
.orderBy('gallery_guests.created_at', 'desc');
|
||||||
|
|
||||||
const guests = rows.map((r) => ({
|
// Which rows are the same person registered more than once (#1210).
|
||||||
...serializeGuest(r),
|
//
|
||||||
stats: {
|
// Registration always inserts, so a returning client whose token has
|
||||||
likes: parseInt(r.likes, 10) || 0,
|
// expired — or who opens the gallery on a second device — becomes a new
|
||||||
favorites: parseInt(r.favorites, 10) || 0,
|
// guest, and their picks split across the copies. Merging those already
|
||||||
comments: parseInt(r.comments, 10) || 0,
|
// works; nothing told the admin which rows to merge, so the split
|
||||||
ratings: parseInt(r.ratings, 10) || 0,
|
// selection had to be spotted by eye before the "final" list could be
|
||||||
reactions: parseInt(r.reactions, 10) || 0,
|
// trusted.
|
||||||
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
|
//
|
||||||
},
|
// Grouped in JS rather than a second grouped query: the list is one
|
||||||
}));
|
// event's guests, and the rows are already in hand. Case-folded because
|
||||||
|
// the same person types Tina@ and tina@ on different days, and trimmed
|
||||||
|
// because a trailing space is invisible in the admin list — both would
|
||||||
|
// otherwise read as distinct people. Email is the only key used: two
|
||||||
|
// guests genuinely called "Anna" are not evidence of anything.
|
||||||
|
const byEmail = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = (r.email || '').trim().toLowerCase();
|
||||||
|
if (!key) continue;
|
||||||
|
if (!byEmail.has(key)) byEmail.set(key, []);
|
||||||
|
byEmail.get(key).push(r.id);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ guests });
|
const guests = rows.map((r) => {
|
||||||
|
const key = (r.email || '').trim().toLowerCase();
|
||||||
|
const sharing = key ? byEmail.get(key) || [] : [];
|
||||||
|
return {
|
||||||
|
...serializeGuest(r),
|
||||||
|
// A group key, not the list of sibling ids (#1210 review). Listing
|
||||||
|
// the others meant every row carried the other n-1 ids, so a group of
|
||||||
|
// n registrations serialised n² ids — and nothing consumed them: the
|
||||||
|
// UI only asks whether a row is in a group and then regroups by this
|
||||||
|
// key anyway. Emitting the normalised email keeps the payload linear
|
||||||
|
// AND keeps the case/whitespace folding in one place instead of
|
||||||
|
// reimplemented on the client.
|
||||||
|
duplicate_group: sharing.length > 1 ? key : null,
|
||||||
|
stats: {
|
||||||
|
likes: parseInt(r.likes, 10) || 0,
|
||||||
|
favorites: parseInt(r.favorites, 10) || 0,
|
||||||
|
comments: parseInt(r.comments, 10) || 0,
|
||||||
|
ratings: parseInt(r.ratings, 10) || 0,
|
||||||
|
reactions: parseInt(r.reactions, 10) || 0,
|
||||||
|
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// One number for the banner, so the UI does not have to derive it and
|
||||||
|
// then disagree with the badges when the derivation drifts.
|
||||||
|
const duplicateGroups = [...byEmail.values()].filter((ids) => ids.length > 1);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
guests,
|
||||||
|
duplicates: {
|
||||||
|
groups: duplicateGroups.length,
|
||||||
|
guests: duplicateGroups.reduce((n, ids) => n + ids.length, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to list guests');
|
errorResponse(res, error, 500, 'Failed to list guests');
|
||||||
}
|
}
|
||||||
@@ -598,6 +643,38 @@ router.post(
|
|||||||
|
|
||||||
const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number));
|
const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number));
|
||||||
|
|
||||||
|
// Canonicalise the survivor's address (#1210 review). Rows are grouped
|
||||||
|
// for review with the case and whitespace folded out, so a merge can be
|
||||||
|
// proposed between `[email protected]` and `[email protected] ` — and if
|
||||||
|
// the non-canonical one survives, guest recovery can never find it
|
||||||
|
// again: /guest/recover lowercases and trims what the guest types, then
|
||||||
|
// matches on equality (galleryGuests.js). Every write path normalises
|
||||||
|
// today, so this is for rows that predate that, which are exactly the
|
||||||
|
// rows case-folded grouping surfaces.
|
||||||
|
const survivor = all.find((g) => Number(g.id) === Number(keepId));
|
||||||
|
if (survivor?.email) {
|
||||||
|
const canonical = String(survivor.email).trim().toLowerCase();
|
||||||
|
if (canonical !== survivor.email) {
|
||||||
|
await db('gallery_guests').where({ id: Number(keepId) }).update({ email: canonical });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry any unredeemed invite over to the survivor BEFORE the source row
|
||||||
|
// is soft-deleted (#1210 review). guest_invites.guest_id points at a real
|
||||||
|
// gallery_guests row — creating an invite inserts one — and redemption
|
||||||
|
// looks it up with `is_deleted: false`. Merging without this leaves the
|
||||||
|
// emailed link resolving to a deleted guest: the client gets a 404
|
||||||
|
// `guest_missing` while the admin's invite dialog still shows the invite
|
||||||
|
// as Pending, so nothing anywhere says the link is dead.
|
||||||
|
//
|
||||||
|
// Only unredeemed, unrevoked invites move. A spent invite is a historical
|
||||||
|
// record of who redeemed what and retargeting it would rewrite that.
|
||||||
|
await db('guest_invites')
|
||||||
|
.whereIn('guest_id', mergeIds.map(Number))
|
||||||
|
.whereNull('redeemed_at')
|
||||||
|
.whereNull('revoked_at')
|
||||||
|
.update({ guest_id: Number(keepId) });
|
||||||
|
|
||||||
// Soft-delete the merged (source) guests.
|
// Soft-delete the merged (source) guests.
|
||||||
await db('gallery_guests')
|
await db('gallery_guests')
|
||||||
.whereIn('id', mergeIds.map(Number))
|
.whereIn('id', mergeIds.map(Number))
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ services:
|
|||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:[email protected]}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:[email protected]}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||||
|
# How long a gallery guest stays recognised (#1210). Unset = 30d.
|
||||||
|
# Listed explicitly because this service takes an environment list, so a
|
||||||
|
# value in .env that is not named here never reaches the container.
|
||||||
|
- GUEST_TOKEN_TTL=${GUEST_TOKEN_TTL:-}
|
||||||
- DATABASE_CLIENT=pg
|
- DATABASE_CLIENT=pg
|
||||||
- DB_TYPE=postgresql
|
- DB_TYPE=postgresql
|
||||||
- DB_HOST=postgres
|
- DB_HOST=postgres
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
|
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
|
||||||
@@ -25,6 +25,8 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
|||||||
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
|
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
|
||||||
const [mergeMode, setMergeMode] = useState(false);
|
const [mergeMode, setMergeMode] = useState(false);
|
||||||
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
|
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
|
||||||
|
// Which row absorbs the others. Never defaulted: see the grouping comment.
|
||||||
|
const [keepId, setKeepId] = useState<number | null>(null);
|
||||||
const inviteModal = useModal();
|
const inviteModal = useModal();
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery({
|
const { data, isLoading, refetch } = useQuery({
|
||||||
@@ -47,6 +49,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMergeMode(false);
|
setMergeMode(false);
|
||||||
setMergeSelection([]);
|
setMergeSelection([]);
|
||||||
|
setKeepId(null);
|
||||||
},
|
},
|
||||||
errorMessage: () => t('admin.guests.mergedError', 'Failed to merge guests'),
|
errorMessage: () => t('admin.guests.mergedError', 'Failed to merge guests'),
|
||||||
});
|
});
|
||||||
@@ -100,23 +103,58 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
|||||||
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
|
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [keepId, ...mergeIds] = mergeSelection;
|
if (keepId === null || !mergeSelection.includes(keepId)) {
|
||||||
const keepName = data?.guests.find((g) => g.id === keepId)?.name;
|
toast.warning(t('admin.guests.mergePickKeep', 'Choose which entry to keep'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mergeIds = mergeSelection.filter((id) => id !== keepId);
|
||||||
|
const keep = data?.guests.find((g) => g.id === keepId);
|
||||||
|
// Name plus email (#1210 review): duplicates are the same person, so the
|
||||||
|
// names are usually identical — "Merge 2 guests into Tina?" told the admin
|
||||||
|
// nothing about which Tina is about to absorb the other.
|
||||||
|
const keepLabel = keep
|
||||||
|
? [keep.name, keep.email].filter(Boolean).join(' · ')
|
||||||
|
: `#${keepId}`;
|
||||||
const confirmMsg = t(
|
const confirmMsg = t(
|
||||||
'admin.guests.mergeConfirm',
|
'admin.guests.mergeConfirm',
|
||||||
'Merge {{count}} guests into {{name}}? This cannot be undone.',
|
'Merge {{count}} guests into {{name}}? This cannot be undone.',
|
||||||
{ count: mergeSelection.length, name: keepName || '#' + keepId }
|
{ count: mergeSelection.length, name: keepLabel }
|
||||||
);
|
);
|
||||||
if (window.confirm(confirmMsg)) {
|
if (window.confirm(confirmMsg)) {
|
||||||
mergeMutation.mutate({ keepId, mergeIds });
|
mergeMutation.mutate({ keepId, mergeIds });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stable identity so the duplicate grouping below is not recomputed on
|
||||||
|
// every render by a fresh [] literal.
|
||||||
|
const guests = useMemo(() => data?.guests || [], [data?.guests]);
|
||||||
|
|
||||||
|
// Derived from the rows the badges render, not from the API's summary count,
|
||||||
|
// so a banner saying "3 entries" can never sit above rows where only 2 are
|
||||||
|
// badged. The API returns the summary too; it is a cheap cross-check, not a
|
||||||
|
// second source of truth.
|
||||||
|
const duplicateGroups = useMemo(() => {
|
||||||
|
const byGroup = new Map<string, AdminGuest[]>();
|
||||||
|
for (const g of guests) {
|
||||||
|
if (!g.duplicate_group) continue;
|
||||||
|
if (!byGroup.has(g.duplicate_group)) byGroup.set(g.duplicate_group, []);
|
||||||
|
byGroup.get(g.duplicate_group)!.push(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately NOT ordered to imply a survivor (#1210 review, three
|
||||||
|
// rounds on this one point). Every automatic rule was wrong somewhere:
|
||||||
|
// most-feedback is guest-controlled, and oldest-first keeps the row whose
|
||||||
|
// token expired while deleting the visitor's currently active identity —
|
||||||
|
// the exact shape of the common case. The data does not say which row is
|
||||||
|
// really the person, so the UI asks instead of guessing.
|
||||||
|
return [...byGroup.values()].filter((group) => group.length > 1);
|
||||||
|
}, [guests]);
|
||||||
|
const duplicateCount = duplicateGroups.reduce((n, group) => n + group.length, 0);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
|
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guests = data?.guests || [];
|
|
||||||
|
|
||||||
if (view === 'aggregate') {
|
if (view === 'aggregate') {
|
||||||
return (
|
return (
|
||||||
@@ -145,10 +183,15 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
|||||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
|
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2}>
|
{keepId === null && (
|
||||||
|
<span className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
{t('admin.guests.mergePickKeepHint', 'Pick the entry to keep')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2 || keepId === null}>
|
||||||
{t('admin.guests.mergeNow', 'Merge selected')}
|
{t('admin.guests.mergeNow', 'Merge selected')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); }}>
|
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); setKeepId(null); }}>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -199,6 +242,40 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The one thing the admin could not see (#1210). Registration always
|
||||||
|
inserts, so a client returning after their token expired — or on a
|
||||||
|
second device — becomes another row and their picks split across the
|
||||||
|
copies. Merging was already here; knowing WHICH rows to merge was
|
||||||
|
not, and a split selection is invisible until someone notices two
|
||||||
|
"Tina"s with half the likes each.
|
||||||
|
|
||||||
|
Preselects the group rather than merging for them: which row survives
|
||||||
|
decides which name and verification state the merged guest keeps, and
|
||||||
|
that is the admin's call, not a default. */}
|
||||||
|
{duplicateGroups.length > 0 && !mergeMode && (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/30 px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
{t('admin.guests.duplicatesFound', {
|
||||||
|
guests: duplicateCount,
|
||||||
|
groups: duplicateGroups.length,
|
||||||
|
defaultValue: '{{guests}} guest entries look like {{groups}} returning visitor(s) — same email, registered more than once. Their picks are split until they are merged.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setMergeMode(true);
|
||||||
|
setMergeSelection(duplicateGroups[0].map((g) => g.id));
|
||||||
|
setKeepId(null);
|
||||||
|
}}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{t('admin.guests.reviewDuplicates', 'Review')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{guests.length === 0 ? (
|
{guests.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
|
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||||
@@ -212,6 +289,11 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
|||||||
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<tr>
|
<tr>
|
||||||
{mergeMode && <th className="px-4 py-3 w-8" />}
|
{mergeMode && <th className="px-4 py-3 w-8" />}
|
||||||
|
{mergeMode && (
|
||||||
|
<th className="px-4 py-3 w-16 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||||
|
{t('admin.guests.mergeKeepColumn', 'Keep')}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||||
{t('admin.guests.columns.name', 'Name')}
|
{t('admin.guests.columns.name', 'Name')}
|
||||||
</th>
|
</th>
|
||||||
@@ -249,12 +331,28 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
aria-label={t('admin.guests.mergeInclude', 'Include {{name}} in the merge', { name: guest.name })}
|
||||||
checked={mergeSelection.includes(guest.id)}
|
checked={mergeSelection.includes(guest.id)}
|
||||||
onChange={() => toggleMergeSelection(guest.id)}
|
onChange={() => toggleMergeSelection(guest.id)}
|
||||||
className="w-4 h-4 text-accent rounded focus:ring-primary-500"
|
className="w-4 h-4 text-accent rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
{mergeMode && (
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{/* The survivor, chosen rather than derived. Only
|
||||||
|
selectable among the rows actually being merged. */}
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="merge-keep"
|
||||||
|
aria-label={t('admin.guests.mergeKeepRow', 'Keep {{name}}', { name: guest.name })}
|
||||||
|
checked={keepId === guest.id}
|
||||||
|
disabled={!mergeSelection.includes(guest.id)}
|
||||||
|
onChange={() => setKeepId(guest.id)}
|
||||||
|
className="w-4 h-4 text-accent focus:ring-primary-500 disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
|
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{guest.name}
|
{guest.name}
|
||||||
{guest.email_verified_at && (
|
{guest.email_verified_at && (
|
||||||
@@ -263,6 +361,14 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{guest.email || '—'}
|
{guest.email || '—'}
|
||||||
|
{guest.duplicate_group && (
|
||||||
|
<span
|
||||||
|
className="ml-2 inline-block rounded px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/50 text-amber-800 dark:text-amber-200"
|
||||||
|
title={t('admin.guests.duplicateHint', 'Another entry on this gallery uses the same email — likely the same person registered twice.')}
|
||||||
|
>
|
||||||
|
{t('admin.guests.duplicateBadge', 'duplicate?')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{guest.stats.likes}
|
{guest.stats.likes}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Surfacing duplicate guests in the admin list (#1210).
|
||||||
|
*
|
||||||
|
* Merging two rows into one already worked. What the admin had no way to see
|
||||||
|
* was WHICH rows were the same person — so a client who registered again after
|
||||||
|
* their token expired left their picks split across two entries, and the
|
||||||
|
* "final selection" was only trustworthy if someone happened to notice.
|
||||||
|
*
|
||||||
|
* The banner offers the group to the merge mode that already exists; it does
|
||||||
|
* not merge anything. Which row survives decides the name and verification
|
||||||
|
* state the merged guest keeps, and that is the admin's call.
|
||||||
|
*/
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { AdminGuestsList } from '../AdminGuestsList';
|
||||||
|
|
||||||
|
const getEventGuests = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('react-i18next', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
// Interpolates like the real i18n so aria-labels built from
|
||||||
|
// `t(key, 'Keep {{name}}', { name })` are queryable by their rendered text.
|
||||||
|
t: (_key: string, fallback?: any, opts?: any) => {
|
||||||
|
if (typeof fallback === 'string') {
|
||||||
|
if (!opts) return fallback;
|
||||||
|
return Object.entries(opts).reduce(
|
||||||
|
(acc, [k, v]) => acc.replaceAll(`{{${k}}}`, String(v)),
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
|
||||||
|
return String(fallback.defaultValue)
|
||||||
|
.replace('{{guests}}', String(fallback.guests))
|
||||||
|
.replace('{{groups}}', String(fallback.groups));
|
||||||
|
}
|
||||||
|
return _key;
|
||||||
|
},
|
||||||
|
i18n: { language: 'en' }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../services/guests.service', () => ({
|
||||||
|
guestsService: {
|
||||||
|
getEventGuests: (...a: any[]) => getEventGuests(...a),
|
||||||
|
deleteGuest: vi.fn(),
|
||||||
|
mergeGuests: vi.fn(),
|
||||||
|
exportGuest: vi.fn(),
|
||||||
|
exportAllGuests: vi.fn(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const guest = (
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
email: string | null,
|
||||||
|
duplicate_group: string | null = null,
|
||||||
|
extra: Partial<{ email_verified_at: string | null; created_at: string; distinct_photos: number }> = {},
|
||||||
|
) => ({
|
||||||
|
id, name, email, duplicate_group,
|
||||||
|
created_at: extra.created_at ?? '2026-08-01T10:00:00Z',
|
||||||
|
last_seen_at: '2026-08-02T10:00:00Z',
|
||||||
|
email_verified_at: extra.email_verified_at ?? null,
|
||||||
|
is_deleted: false,
|
||||||
|
stats: {
|
||||||
|
likes: 3, favorites: 1, comments: 0, ratings: 0, reactions: 0, color_labels: 0,
|
||||||
|
distinct_photos: extra.distinct_photos ?? 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderList = () => render(<AdminGuestsList eventId={7} eventName="Test" />, { wrapper });
|
||||||
|
|
||||||
|
describe('duplicate guests in the admin list (#1210)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getEventGuests.mockReset();
|
||||||
|
// Calls leak between cases otherwise — the survivor test below performs a
|
||||||
|
// real merge, and the "does not merge on its own" case asserts on the
|
||||||
|
// absence of exactly that call.
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says how many entries look like returning visitors', async () => {
|
||||||
|
getEventGuests.mockResolvedValue({
|
||||||
|
guests: [guest(1, 'Tina', '[email protected]', '[email protected]'), guest(2, 'Tina', '[email protected]', '[email protected]')],
|
||||||
|
duplicates: { groups: 1, guests: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/2 guest entries look like 1 returning visitor/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('badges the rows the banner is talking about', async () => {
|
||||||
|
getEventGuests.mockResolvedValue({
|
||||||
|
guests: [
|
||||||
|
guest(1, 'Tina', '[email protected]', '[email protected]'),
|
||||||
|
guest(2, 'Tina', '[email protected]', '[email protected]'),
|
||||||
|
guest(3, 'Marc', '[email protected]'),
|
||||||
|
],
|
||||||
|
duplicates: { groups: 1, guests: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
// Two badged, and Marc left alone — the banner's claim is checkable
|
||||||
|
// against the rows rather than being taken on trust.
|
||||||
|
expect(await screen.findAllByText(/duplicate\?/i)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays out of the way when nobody is duplicated', async () => {
|
||||||
|
getEventGuests.mockResolvedValue({
|
||||||
|
guests: [guest(1, 'Tina', '[email protected]'), guest(2, 'Marc', '[email protected]')],
|
||||||
|
duplicates: { groups: 0, guests: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Marc')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/returning visitor/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/duplicate\?/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to merge until the admin says which entry survives', async () => {
|
||||||
|
// Three review rounds went into this: every automatic survivor rule was
|
||||||
|
// wrong somewhere. Most-feedback is guest-controlled; oldest-first keeps
|
||||||
|
// the row whose token expired and deletes the visitor's live identity. The
|
||||||
|
// data cannot answer it, so the UI must ask.
|
||||||
|
const { guestsService } = await import('../../../services/guests.service');
|
||||||
|
getEventGuests.mockResolvedValue({
|
||||||
|
guests: [
|
||||||
|
guest(2, 'Tina', '[email protected]', '[email protected]'),
|
||||||
|
guest(1, 'Tina Ferrarelli', '[email protected]', '[email protected]',
|
||||||
|
{ email_verified_at: '2026-08-01T10:14:00Z' }),
|
||||||
|
],
|
||||||
|
duplicates: { groups: 1, guests: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /^Review$/i }));
|
||||||
|
|
||||||
|
// Group is ticked, but nothing is nominated to survive yet.
|
||||||
|
expect((await screen.findAllByRole('checkbox')).filter((c) => (c as HTMLInputElement).checked)).toHaveLength(2);
|
||||||
|
expect(screen.getAllByRole('radio').every((r) => !(r as HTMLInputElement).checked)).toBe(true);
|
||||||
|
expect(screen.getByRole('button', { name: /merge selected/i })).toBeDisabled();
|
||||||
|
|
||||||
|
// Choosing one enables it, and that is the id the merge keeps.
|
||||||
|
await userEvent.click(screen.getByRole('radio', { name: /Keep Tina Ferrarelli/i }));
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /merge selected/i }));
|
||||||
|
|
||||||
|
expect(guestsService.mergeGuests).toHaveBeenCalledWith(7, 1, [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will not nominate a row that is not part of the merge', async () => {
|
||||||
|
getEventGuests.mockResolvedValue({
|
||||||
|
guests: [
|
||||||
|
guest(1, 'Tina', '[email protected]', '[email protected]'),
|
||||||
|
guest(2, 'Tina', '[email protected]', '[email protected]'),
|
||||||
|
guest(3, 'Marc', '[email protected]'),
|
||||||
|
],
|
||||||
|
duplicates: { groups: 1, guests: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /^Review$/i }));
|
||||||
|
|
||||||
|
// Marc is not in the group, so he cannot be made the survivor of it.
|
||||||
|
expect(await screen.findByRole('radio', { name: /Keep Marc/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands the group to the merge flow instead of merging on its own', async () => {
|
||||||
|
const { guestsService } = await import('../../../services/guests.service');
|
||||||
|
getEventGuests.mockResolvedValue({
|
||||||
|
guests: [guest(1, 'Tina', '[email protected]', '[email protected]'), guest(2, 'Tina', '[email protected]', '[email protected]')],
|
||||||
|
duplicates: { groups: 1, guests: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /^Review$/i }));
|
||||||
|
|
||||||
|
// Merge mode is open with the pair preselected, and nothing has been
|
||||||
|
// merged — the admin still chooses which row survives.
|
||||||
|
expect(guestsService.mergeGuests).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText(/returning visitor/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3011,7 +3011,16 @@
|
|||||||
"pending": "Ausstehend",
|
"pending": "Ausstehend",
|
||||||
"redeemed": "Eingelöst",
|
"redeemed": "Eingelöst",
|
||||||
"revoked": "Widerrufen"
|
"revoked": "Widerrufen"
|
||||||
}
|
},
|
||||||
|
"duplicatesFound": "{{guests}} Gasteinträge sehen nach {{groups}} wiederkehrenden Besucher(n) aus — gleiche E-Mail, mehrfach registriert. Ihre Auswahl bleibt geteilt, bis sie zusammengeführt werden.",
|
||||||
|
"reviewDuplicates": "Prüfen",
|
||||||
|
"duplicateBadge": "Duplikat?",
|
||||||
|
"duplicateHint": "Ein anderer Eintrag in dieser Galerie nutzt dieselbe E-Mail — vermutlich dieselbe Person, zweimal registriert.",
|
||||||
|
"mergePickKeep": "Bitte wählen, welcher Eintrag bleiben soll",
|
||||||
|
"mergePickKeepHint": "Eintrag zum Behalten wählen",
|
||||||
|
"mergeKeepColumn": "Behalten",
|
||||||
|
"mergeKeepRow": "{{name}} behalten",
|
||||||
|
"mergeInclude": "{{name}} in die Zusammenführung einbeziehen"
|
||||||
},
|
},
|
||||||
"analytics": "Analytik",
|
"analytics": "Analytik",
|
||||||
"activities": {
|
"activities": {
|
||||||
|
|||||||
@@ -2586,7 +2586,16 @@
|
|||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"redeemed": "Redeemed",
|
"redeemed": "Redeemed",
|
||||||
"revoked": "Revoked"
|
"revoked": "Revoked"
|
||||||
}
|
},
|
||||||
|
"duplicatesFound": "{{guests}} guest entries look like {{groups}} returning visitor(s) — same email, registered more than once. Their picks are split until they are merged.",
|
||||||
|
"reviewDuplicates": "Review",
|
||||||
|
"duplicateBadge": "duplicate?",
|
||||||
|
"duplicateHint": "Another entry on this gallery uses the same email — likely the same person registered twice.",
|
||||||
|
"mergePickKeep": "Choose which entry to keep",
|
||||||
|
"mergePickKeepHint": "Pick the entry to keep",
|
||||||
|
"mergeKeepColumn": "Keep",
|
||||||
|
"mergeKeepRow": "Keep {{name}}",
|
||||||
|
"mergeInclude": "Include {{name}} in the merge"
|
||||||
},
|
},
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"activities": {
|
"activities": {
|
||||||
|
|||||||
@@ -1787,7 +1787,16 @@
|
|||||||
"pending": "En attente",
|
"pending": "En attente",
|
||||||
"redeemed": "Utilisé",
|
"redeemed": "Utilisé",
|
||||||
"revoked": "Révoqué"
|
"revoked": "Révoqué"
|
||||||
}
|
},
|
||||||
|
"duplicatesFound": "{{guests}} entrées invité ressemblent à {{groups}} visiteur(s) de retour — même e-mail, inscrit plusieurs fois. Leurs choix restent séparés tant qu’ils ne sont pas fusionnés.",
|
||||||
|
"reviewDuplicates": "Vérifier",
|
||||||
|
"duplicateBadge": "doublon ?",
|
||||||
|
"duplicateHint": "Une autre entrée de cette galerie utilise le même e-mail — probablement la même personne inscrite deux fois.",
|
||||||
|
"mergePickKeep": "Choisissez l'entrée à conserver",
|
||||||
|
"mergePickKeepHint": "Choisir l'entrée à conserver",
|
||||||
|
"mergeKeepColumn": "Conserver",
|
||||||
|
"mergeKeepRow": "Conserver {{name}}",
|
||||||
|
"mergeInclude": "Inclure {{name}} dans la fusion"
|
||||||
},
|
},
|
||||||
"analytics": "Analytique",
|
"analytics": "Analytique",
|
||||||
"activities": {
|
"activities": {
|
||||||
|
|||||||
@@ -1756,7 +1756,16 @@
|
|||||||
"pending": "In behandeling",
|
"pending": "In behandeling",
|
||||||
"redeemed": "Ingewisseld",
|
"redeemed": "Ingewisseld",
|
||||||
"revoked": "Ingetrokken"
|
"revoked": "Ingetrokken"
|
||||||
}
|
},
|
||||||
|
"duplicatesFound": "{{guests}} gastvermeldingen lijken {{groups}} terugkerende bezoeker(s) — zelfde e-mail, meerdere keren geregistreerd. Hun keuzes blijven gesplitst tot ze zijn samengevoegd.",
|
||||||
|
"reviewDuplicates": "Bekijken",
|
||||||
|
"duplicateBadge": "duplicaat?",
|
||||||
|
"duplicateHint": "Een andere vermelding in deze galerij gebruikt hetzelfde e-mailadres — waarschijnlijk dezelfde persoon die zich twee keer registreerde.",
|
||||||
|
"mergePickKeep": "Kies welk item behouden blijft",
|
||||||
|
"mergePickKeepHint": "Kies het item om te behouden",
|
||||||
|
"mergeKeepColumn": "Behouden",
|
||||||
|
"mergeKeepRow": "{{name}} behouden",
|
||||||
|
"mergeInclude": "{{name}} in de samenvoeging opnemen"
|
||||||
},
|
},
|
||||||
"analytics": "Analyse",
|
"analytics": "Analyse",
|
||||||
"activities": {
|
"activities": {
|
||||||
|
|||||||
@@ -1781,7 +1781,16 @@
|
|||||||
"pending": "Pendente",
|
"pending": "Pendente",
|
||||||
"redeemed": "Utilizado",
|
"redeemed": "Utilizado",
|
||||||
"revoked": "Revogado"
|
"revoked": "Revogado"
|
||||||
}
|
},
|
||||||
|
"duplicatesFound": "{{guests}} entradas de convidado parecem {{groups}} visitante(s) recorrente(s) — mesmo e-mail, registado mais do que uma vez. As escolhas ficam divididas até serem unidas.",
|
||||||
|
"reviewDuplicates": "Rever",
|
||||||
|
"duplicateBadge": "duplicado?",
|
||||||
|
"duplicateHint": "Outra entrada nesta galeria usa o mesmo e-mail — provavelmente a mesma pessoa registada duas vezes.",
|
||||||
|
"mergePickKeep": "Escolha qual entrada manter",
|
||||||
|
"mergePickKeepHint": "Escolha a entrada a manter",
|
||||||
|
"mergeKeepColumn": "Manter",
|
||||||
|
"mergeKeepRow": "Manter {{name}}",
|
||||||
|
"mergeInclude": "Incluir {{name}} na junção"
|
||||||
},
|
},
|
||||||
"analytics": "Análise",
|
"analytics": "Análise",
|
||||||
"activities": {
|
"activities": {
|
||||||
|
|||||||
@@ -1806,7 +1806,16 @@
|
|||||||
"pending": "Ожидает",
|
"pending": "Ожидает",
|
||||||
"redeemed": "Использовано",
|
"redeemed": "Использовано",
|
||||||
"revoked": "Отозвано"
|
"revoked": "Отозвано"
|
||||||
}
|
},
|
||||||
|
"duplicatesFound": "{{guests}} записей гостей похожи на {{groups}} вернувшихся посетителей — тот же e-mail, регистрация несколько раз. Их выбор останется разделённым, пока записи не объединят.",
|
||||||
|
"reviewDuplicates": "Проверить",
|
||||||
|
"duplicateBadge": "дубликат?",
|
||||||
|
"duplicateHint": "Другая запись в этой галерее использует тот же e-mail — вероятно, тот же человек зарегистрировался дважды.",
|
||||||
|
"mergePickKeep": "Выберите, какую запись сохранить",
|
||||||
|
"mergePickKeepHint": "Выберите запись для сохранения",
|
||||||
|
"mergeKeepColumn": "Сохранить",
|
||||||
|
"mergeKeepRow": "Сохранить {{name}}",
|
||||||
|
"mergeInclude": "Включить {{name}} в объединение"
|
||||||
},
|
},
|
||||||
"analytics": "Аналитика",
|
"analytics": "Аналитика",
|
||||||
"activities": {
|
"activities": {
|
||||||
|
|||||||
@@ -1776,7 +1776,16 @@
|
|||||||
"pending": "Čaka",
|
"pending": "Čaka",
|
||||||
"redeemed": "Unovčeno",
|
"redeemed": "Unovčeno",
|
||||||
"revoked": "Preklicano"
|
"revoked": "Preklicano"
|
||||||
}
|
},
|
||||||
|
"duplicatesFound": "{{guests}} vnosov gostov je videti kot {{groups}} vračajočih se obiskovalcev — isti e-naslov, večkrat registriran. Njihova izbira ostane razdeljena, dokler jih ne združite.",
|
||||||
|
"reviewDuplicates": "Preveri",
|
||||||
|
"duplicateBadge": "dvojnik?",
|
||||||
|
"duplicateHint": "Drug vnos v tej galeriji uporablja isti e-naslov — najverjetneje ista oseba, registrirana dvakrat.",
|
||||||
|
"mergePickKeep": "Izberite, kateri vnos naj ostane",
|
||||||
|
"mergePickKeepHint": "Izberite vnos za ohranitev",
|
||||||
|
"mergeKeepColumn": "Ohrani",
|
||||||
|
"mergeKeepRow": "Ohrani {{name}}",
|
||||||
|
"mergeInclude": "Vključi {{name}} v združitev"
|
||||||
},
|
},
|
||||||
"analytics": "Analitika",
|
"analytics": "Analitika",
|
||||||
"activities": {
|
"activities": {
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export interface AdminGuest {
|
|||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
email_verified_at: string | null;
|
email_verified_at: string | null;
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
|
// Set when this row shares an email with another on the same event (#1210)
|
||||||
|
// — the same person who registered again after their token expired or on a
|
||||||
|
// second device. The value is the normalised email, so it doubles as the
|
||||||
|
// grouping key; null for everyone else.
|
||||||
|
duplicate_group?: string | null;
|
||||||
stats: AdminGuestStats;
|
stats: AdminGuestStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +115,10 @@ class GuestsService {
|
|||||||
// Admin-side
|
// Admin-side
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
async getEventGuests(eventId: number): Promise<{ guests: AdminGuest[] }> {
|
async getEventGuests(eventId: number): Promise<{
|
||||||
|
guests: AdminGuest[];
|
||||||
|
duplicates?: { groups: number; guests: number };
|
||||||
|
}> {
|
||||||
const response = await api.get(`/admin/events/${eventId}/guests`);
|
const response = await api.get(`/admin/events/${eventId}/guests`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user