@mohasinac/appkit 4.77.0 → 4.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/features/events/actions/event-actions.js +41 -1
- package/dist/features/events/components/EventsIndexListing.js +36 -1
- package/dist/features/events/schemas/firestore.d.ts +69 -0
- package/dist/features/events/schemas/firestore.js +56 -0
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.78.0",
|
|
4
|
+
"head": "fd8f0ee8109c0e0c16c567de8d8263c2bfa71551",
|
|
5
5
|
"branch": "main",
|
|
6
|
-
"dirty":
|
|
7
|
-
"builtAt": "2026-11-
|
|
6
|
+
"dirty": true,
|
|
7
|
+
"builtAt": "2026-11-09T13:25:12.547Z"
|
|
8
8
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveEntryApproval } from "../schemas/firestore.js";
|
|
1
|
+
import { resolveEntryApproval, checkEntryMedia, resolveEntryLimit, } from "../schemas/firestore.js";
|
|
2
2
|
/**
|
|
3
3
|
* Event Domain Actions (appkit/features/events)
|
|
4
4
|
*
|
|
@@ -546,6 +546,46 @@ export async function enterEvent(eventId, input, user, request) {
|
|
|
546
546
|
* override the owner asked for works completely; the type-level default is
|
|
547
547
|
* the next step and slots into the middle argument with no other change.
|
|
548
548
|
*/
|
|
549
|
+
/*
|
|
550
|
+
* ── THE EVENT'S OWN ENTRY RULES, enforced on the SERVER ──────────────
|
|
551
|
+
*
|
|
552
|
+
* 🛑 Both checks live here rather than only in the route's zod schema,
|
|
553
|
+
* because zod cannot see the EVENT. The schema can bound request size with
|
|
554
|
+
* a fixed ceiling; only this point knows that THIS contest allows 3 photos
|
|
555
|
+
* and 1 video and one entry per person.
|
|
556
|
+
*
|
|
557
|
+
* 🛑 And a client-side form that hides the submit button is not
|
|
558
|
+
* enforcement — the POST is still reachable. This is the write path, so it
|
|
559
|
+
* is where the limits have to hold.
|
|
560
|
+
*/
|
|
561
|
+
const mediaProblem = checkEntryMedia(input.media, event.entryMedia);
|
|
562
|
+
if (mediaProblem)
|
|
563
|
+
throw new ValidationError(mediaProblem);
|
|
564
|
+
/*
|
|
565
|
+
* One entry per participant by default (owner, 2026-11-09: "for now block
|
|
566
|
+
* single entry only"), overridable per event via `entryLimit`.
|
|
567
|
+
*
|
|
568
|
+
* ⚠ Checked for signed-in users only. A guest entry has no stable identity
|
|
569
|
+
* to count against — `guestIpHash` exists but an IP is shared by households
|
|
570
|
+
* and whole offices, so counting on it would block real people. Guest
|
|
571
|
+
* participation is gated separately by `allowGuestParticipation`; an event
|
|
572
|
+
* that needs a hard per-person limit should require sign-in, which is what
|
|
573
|
+
* the contest does.
|
|
574
|
+
*
|
|
575
|
+
* ⚠ NOT race-safe against two simultaneous submissions from one account.
|
|
576
|
+
* Making it so needs a transaction around count+create, or a deterministic
|
|
577
|
+
* entry id like `${eventId}:${uid}`. Stated rather than implied: the window
|
|
578
|
+
* is small and the consequence is a duplicate entry, not a lost one.
|
|
579
|
+
*/
|
|
580
|
+
if (user?.uid) {
|
|
581
|
+
const limit = resolveEntryLimit(event.entryLimit);
|
|
582
|
+
const already = await eventEntryRepository.countUserEntries(resolvedEventId, user.uid);
|
|
583
|
+
if (already >= limit) {
|
|
584
|
+
throw new ValidationError(limit === 1
|
|
585
|
+
? "You have already entered this event."
|
|
586
|
+
: `You have reached the limit of ${limit} entries for this event.`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
549
589
|
const approvalMode = resolveEntryApproval(event, undefined, legacyAutoApprove);
|
|
550
590
|
const autoApprove = approvalMode === "auto";
|
|
551
591
|
const reviewStatus = autoApprove ? "approved" : "pending";
|
|
@@ -5,6 +5,7 @@ import { useUrlTable } from "../../../react/hooks/useUrlTable.js";
|
|
|
5
5
|
import { useEvents } from "../hooks/useEvents.js";
|
|
6
6
|
import { Div } from "../../../ui/components/Div.js";
|
|
7
7
|
import { Grid, Row, Stack } from "../../../ui/components/Layout.js";
|
|
8
|
+
import { Button } from "../../../ui/components/Button.js";
|
|
8
9
|
import { ListingFilterDrawer } from "../../../ui/components/ListingFilterDrawer.js";
|
|
9
10
|
import { ListingToolbar, applyToggleVisibility } from "../../../ui/components/ListingToolbar.js";
|
|
10
11
|
import { useFieldVisibility } from "../../shell/useFieldVisibility.js";
|
|
@@ -119,6 +120,24 @@ export function EventsIndexListing({ initialData, facetCounts }) {
|
|
|
119
120
|
filterParts.push(sieveFilter(EVENT_FIELDS.STARTS_AT, SIEVE_OP.GTE, dateFrom));
|
|
120
121
|
if (dateTo)
|
|
121
122
|
filterParts.push(sieveFilter(EVENT_FIELDS.ENDS_AT, SIEVE_OP.LTE, dateTo));
|
|
123
|
+
/*
|
|
124
|
+
* LIVE / FINISHED tabs, split BY DATE WINDOW rather than by `status`
|
|
125
|
+
* (owner, 2026-11-09): an event is finished when its `endsAt` has passed.
|
|
126
|
+
*
|
|
127
|
+
* Why not `status`: the two can disagree. An event whose `endsAt` passed
|
|
128
|
+
* while nobody moved it off `active` would sit under "Live" forever, and a
|
|
129
|
+
* tab that lies about what is running is worse than no tab. The clock
|
|
130
|
+
* cannot forget to be updated.
|
|
131
|
+
*
|
|
132
|
+
* The "Show expired" toggle stays and the two do NOT fight: it widens the
|
|
133
|
+
* STATUS set, this narrows the DATE window. Selecting Finished with "Show
|
|
134
|
+
* expired" off still works, because a finished event may still be `active`.
|
|
135
|
+
*/
|
|
136
|
+
const phase = table.get(TABLE_KEYS.TAB);
|
|
137
|
+
if (phase === "live" || phase === "finished") {
|
|
138
|
+
const nowIso = new Date().toISOString();
|
|
139
|
+
filterParts.push(sieveFilter(EVENT_FIELDS.ENDS_AT, phase === "live" ? SIEVE_OP.GTE : SIEVE_OP.LT, nowIso));
|
|
140
|
+
}
|
|
122
141
|
const params = {
|
|
123
142
|
q: table.get(TABLE_KEYS.QUERY) || undefined,
|
|
124
143
|
page: table.getNumber(TABLE_KEYS.PAGE, 1),
|
|
@@ -131,7 +150,23 @@ export function EventsIndexListing({ initialData, facetCounts }) {
|
|
|
131
150
|
const commitSearch = useCallback(() => {
|
|
132
151
|
table.set(TABLE_KEYS.QUERY, searchInput.trim());
|
|
133
152
|
}, [searchInput, table]);
|
|
134
|
-
return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(
|
|
153
|
+
return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(Row, { gap: "xs", className: "px-[var(--appkit-space-4)] pt-[var(--appkit-space-3)]", children: [
|
|
154
|
+
{ key: "", label: "All" },
|
|
155
|
+
{ key: "live", label: "Live" },
|
|
156
|
+
{ key: "finished", label: "Finished" },
|
|
157
|
+
].map((t) => {
|
|
158
|
+
const active = (table.get(TABLE_KEYS.TAB) || "") === t.key;
|
|
159
|
+
return (_jsx(Button, { variant: active ? "primary" : "ghost", size: "sm", "aria-pressed": active, onClick: () => {
|
|
160
|
+
/*
|
|
161
|
+
* Reset to page 1. Without this, switching from a set with 5
|
|
162
|
+
* pages to one with 1 leaves `page=4` in the URL and the grid
|
|
163
|
+
* renders empty — which reads as "no finished events" rather
|
|
164
|
+
* than "you are past the end".
|
|
165
|
+
*/
|
|
166
|
+
table.set(TABLE_KEYS.PAGE, "");
|
|
167
|
+
table.set(TABLE_KEYS.TAB, t.key);
|
|
168
|
+
}, children: t.label }, t.key || "all"));
|
|
169
|
+
}) }), _jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search events...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: table.get(TABLE_KEYS.SORT) || sortBy(EVENT_FIELDS.STARTS_AT, "ASC"), sortOptions: EVENT_PUBLIC_SORT_OPTIONS, onSortChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState, toggles: applyToggleVisibility([
|
|
135
170
|
{ label: "Show expired", active: showExpired, onChange: (next) => table.set(TABLE_KEYS.SHOW_EXPIRED, next ? "true" : "") },
|
|
136
171
|
], editMode ? {} : toolbarVisibilityFields, toolbarFieldOrder) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "lg", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-video", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p4}`, gap: "sm", children: [_jsx(Div, { className: "h-4 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-8 mt-2", surface: "subtle", rounded: "default" })] })] }, i))) })) : events.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "No events found." })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: events.map((event) => (_jsx(EventCard, { event: event }, event.id))) })) : (_jsx(Grid, { cols: "cards", gap: "lg", children: events.map((event) => (_jsx(EventCard, { event: event }, event.id))) })) }), _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsx(EventFilters, { table: pendingTable, variant: "public", counts: facetCounts, hideEmpty: Boolean(facetCounts) }) })] }));
|
|
137
172
|
}
|
|
@@ -156,6 +156,36 @@ export interface EventDocument extends BaseDocument {
|
|
|
156
156
|
* no existing event changes behaviour. See `resolveEntryApproval`.
|
|
157
157
|
*/
|
|
158
158
|
entryApproval?: EntryApprovalMode;
|
|
159
|
+
/**
|
|
160
|
+
* How much media ONE entry may carry. Owner spec 2026-11-09: **up to 3
|
|
161
|
+
* photos and 1 video, 4 items total.**
|
|
162
|
+
*
|
|
163
|
+
* 🛑 ON THE EVENT ROW, not in code, for the same reason `entryApproval` is:
|
|
164
|
+
* *"for now block single entry only but later allow config changes for all
|
|
165
|
+
* on the events"*. A second contest wanting 10 photos is a seed edit.
|
|
166
|
+
*
|
|
167
|
+
* The caps were already enforced — but as literals inside the submission
|
|
168
|
+
* route's zod schema (`.min(1).max(4)` plus two `.refine`s). That works for
|
|
169
|
+
* exactly one contest and cannot vary per event, which is the shape this
|
|
170
|
+
* project keeps having to undo.
|
|
171
|
+
*
|
|
172
|
+
* ⚠ zod cannot express these statically once they are per-event, so the
|
|
173
|
+
* schema keeps an ABSOLUTE ceiling (a sanity bound on request size) and the
|
|
174
|
+
* handler enforces THIS event's numbers after loading it. Both run; the
|
|
175
|
+
* schema is the cheap gate, the handler is the real one.
|
|
176
|
+
*/
|
|
177
|
+
entryMedia?: EventEntryMediaLimits;
|
|
178
|
+
/**
|
|
179
|
+
* How many entries ONE participant may submit to this event.
|
|
180
|
+
*
|
|
181
|
+
* Owner, 2026-11-09: *"for now block single entry only"* — so the default
|
|
182
|
+
* is 1, and an event that wants more sets its own number.
|
|
183
|
+
*
|
|
184
|
+
* 🛑 Enforced on the SERVER, not by hiding the form. A client that has
|
|
185
|
+
* already submitted can still POST again; the check belongs where the write
|
|
186
|
+
* happens.
|
|
187
|
+
*/
|
|
188
|
+
entryLimit?: number;
|
|
159
189
|
isTestData?: boolean;
|
|
160
190
|
/** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
|
|
161
191
|
testDataExpiresAt?: Date;
|
|
@@ -555,4 +585,43 @@ export declare function resolveEntryApproval(event: {
|
|
|
555
585
|
type?: string;
|
|
556
586
|
surveyConfig?: unknown;
|
|
557
587
|
}, typeDefault?: EntryApprovalMode, legacyAutoApprove?: boolean): "auto" | "moderated";
|
|
588
|
+
/**
|
|
589
|
+
* Per-entry media caps. All optional — an unset field falls back to the
|
|
590
|
+
* platform default in `resolveEntryMediaLimits`.
|
|
591
|
+
*/
|
|
592
|
+
export interface EventEntryMediaLimits {
|
|
593
|
+
/** Max images in one entry. Platform default: 3. */
|
|
594
|
+
maxImages?: number;
|
|
595
|
+
/** Max videos in one entry. Platform default: 1. */
|
|
596
|
+
maxVideos?: number;
|
|
597
|
+
/** Max items overall. Platform default: 4. */
|
|
598
|
+
maxTotal?: number;
|
|
599
|
+
/** Require at least one image. Platform default: true. */
|
|
600
|
+
requireImage?: boolean;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* The platform defaults, stated once. Owner spec 2026-11-09 for the Diwali
|
|
604
|
+
* photo/video contest: 3 photos + 1 video, 4 total.
|
|
605
|
+
*
|
|
606
|
+
* 🛑 `maxTotal` is NOT `maxImages + maxVideos`. It is a separate cap on
|
|
607
|
+
* purpose: 3 + 1 = 4 today, but an event allowing 5 images and 2 videos may
|
|
608
|
+
* still want a 5-item ceiling. Deriving it would silently remove that choice.
|
|
609
|
+
*/
|
|
610
|
+
export declare const DEFAULT_ENTRY_MEDIA_LIMITS: Required<EventEntryMediaLimits>;
|
|
611
|
+
/** This event's caps, with every unset field filled from the platform default. */
|
|
612
|
+
export declare function resolveEntryMediaLimits(limits?: EventEntryMediaLimits): Required<EventEntryMediaLimits>;
|
|
613
|
+
/**
|
|
614
|
+
* Check one submission's media against an event's caps.
|
|
615
|
+
*
|
|
616
|
+
* Returns a human-readable reason, or `null` when it passes. A string rather
|
|
617
|
+
* than a boolean because the caller surfaces it to the participant — "At most
|
|
618
|
+
* 3 photos" is actionable; "invalid" is not.
|
|
619
|
+
*/
|
|
620
|
+
export declare function checkEntryMedia(media: ReadonlyArray<{
|
|
621
|
+
type: string;
|
|
622
|
+
}> | undefined, limits?: EventEntryMediaLimits): string | null;
|
|
623
|
+
/** How many entries one participant may submit. Platform default: 1. */
|
|
624
|
+
export declare const DEFAULT_ENTRY_LIMIT = 1;
|
|
625
|
+
/** This event's per-participant entry limit. */
|
|
626
|
+
export declare function resolveEntryLimit(entryLimit?: number): number;
|
|
558
627
|
export {};
|
|
@@ -164,3 +164,59 @@ export function resolveEntryApproval(event, typeDefault, legacyAutoApprove) {
|
|
|
164
164
|
return chosen === "auto" ? "auto" : "moderated";
|
|
165
165
|
return legacyAutoApprove ? "auto" : "moderated";
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The platform defaults, stated once. Owner spec 2026-11-09 for the Diwali
|
|
169
|
+
* photo/video contest: 3 photos + 1 video, 4 total.
|
|
170
|
+
*
|
|
171
|
+
* 🛑 `maxTotal` is NOT `maxImages + maxVideos`. It is a separate cap on
|
|
172
|
+
* purpose: 3 + 1 = 4 today, but an event allowing 5 images and 2 videos may
|
|
173
|
+
* still want a 5-item ceiling. Deriving it would silently remove that choice.
|
|
174
|
+
*/
|
|
175
|
+
export const DEFAULT_ENTRY_MEDIA_LIMITS = {
|
|
176
|
+
maxImages: 3,
|
|
177
|
+
maxVideos: 1,
|
|
178
|
+
maxTotal: 4,
|
|
179
|
+
requireImage: true,
|
|
180
|
+
};
|
|
181
|
+
/** This event's caps, with every unset field filled from the platform default. */
|
|
182
|
+
export function resolveEntryMediaLimits(limits) {
|
|
183
|
+
return {
|
|
184
|
+
maxImages: limits?.maxImages ?? DEFAULT_ENTRY_MEDIA_LIMITS.maxImages,
|
|
185
|
+
maxVideos: limits?.maxVideos ?? DEFAULT_ENTRY_MEDIA_LIMITS.maxVideos,
|
|
186
|
+
maxTotal: limits?.maxTotal ?? DEFAULT_ENTRY_MEDIA_LIMITS.maxTotal,
|
|
187
|
+
requireImage: limits?.requireImage ?? DEFAULT_ENTRY_MEDIA_LIMITS.requireImage,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Check one submission's media against an event's caps.
|
|
192
|
+
*
|
|
193
|
+
* Returns a human-readable reason, or `null` when it passes. A string rather
|
|
194
|
+
* than a boolean because the caller surfaces it to the participant — "At most
|
|
195
|
+
* 3 photos" is actionable; "invalid" is not.
|
|
196
|
+
*/
|
|
197
|
+
export function checkEntryMedia(media, limits) {
|
|
198
|
+
const l = resolveEntryMediaLimits(limits);
|
|
199
|
+
const items = media ?? [];
|
|
200
|
+
if (!items.length) {
|
|
201
|
+
return l.requireImage ? "Add at least one photo" : null;
|
|
202
|
+
}
|
|
203
|
+
if (items.length > l.maxTotal)
|
|
204
|
+
return `At most ${l.maxTotal} items in total`;
|
|
205
|
+
const images = items.filter((m) => m.type === "image").length;
|
|
206
|
+
const videos = items.filter((m) => m.type === "video").length;
|
|
207
|
+
if (images > l.maxImages)
|
|
208
|
+
return `At most ${l.maxImages} photos`;
|
|
209
|
+
if (videos > l.maxVideos)
|
|
210
|
+
return `At most ${l.maxVideos} video${l.maxVideos === 1 ? "" : "s"}`;
|
|
211
|
+
if (l.requireImage && images < 1)
|
|
212
|
+
return "At least one photo is required";
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
/** How many entries one participant may submit. Platform default: 1. */
|
|
216
|
+
export const DEFAULT_ENTRY_LIMIT = 1;
|
|
217
|
+
/** This event's per-participant entry limit. */
|
|
218
|
+
export function resolveEntryLimit(entryLimit) {
|
|
219
|
+
return typeof entryLimit === "number" && entryLimit > 0
|
|
220
|
+
? entryLimit
|
|
221
|
+
: DEFAULT_ENTRY_LIMIT;
|
|
222
|
+
}
|