@mohasinac/appkit 4.41.3 → 4.41.5

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.
@@ -18,6 +18,7 @@ import { NextResponse } from "next/server.js";
18
18
  import { getProviders } from "../../../../contracts";
19
19
  import { EVENT_FIELDS } from "../../schemas";
20
20
  import { normalizeError } from "../../../../errors/normalize";
21
+ import { toClientLotteryConfig } from "../../../../_internal/server/features/lottery/adapters";
21
22
  // --- GET /api/events/[id] -----------------------------------------------------
22
23
  export async function GET(_request, context) {
23
24
  try {
@@ -33,8 +34,22 @@ export async function GET(_request, context) {
33
34
  event.status === EVENT_FIELDS.STATUS_VALUES.PAUSED) {
34
35
  return NextResponse.json({ success: false, error: "Event not found" }, { status: 404 });
35
36
  }
36
- // Strip internal field
37
- const { createdBy: _createdBy, ...publicEvent } = event;
37
+ // Strip internal field.
38
+ //
39
+ // 🛑 This is a deny-list spread, so anything NOT named here is published by
40
+ // default — which is how a lottery event's stored `lotteryConfig` reached
41
+ // anonymous callers whole. `toClientLotteryConfig` is the allow-list that
42
+ // belongs on this boundary: it drops the per-slot `price` and `weight` (the
43
+ // weighting is how the odds are set) and the internal `bookedByUserId`,
44
+ // while deliberately keeping `bookedByDisplayName` and
45
+ // `bookedByUserLotteryNumber`, which the public slot grid renders.
46
+ const { createdBy: _createdBy, ...rawPublicEvent } = event;
47
+ const publicEvent = rawPublicEvent.lotteryConfig
48
+ ? {
49
+ ...rawPublicEvent,
50
+ lotteryConfig: toClientLotteryConfig(rawPublicEvent.lotteryConfig),
51
+ }
52
+ : rawPublicEvent;
38
53
  const entriesRepo = db.getRepository("eventEntries");
39
54
  // For poll events with resultsVisibility='always': compute live vote tallies
40
55
  let pollResults = [];
@@ -162,8 +162,17 @@ function stringMaxLength(schema) {
162
162
  */
163
163
  function enumOptions(schema) {
164
164
  const direct = schema.options;
165
- if (Array.isArray(direct))
166
- return direct.filter((o) => typeof o === "string");
165
+ if (Array.isArray(direct)) {
166
+ const strings = direct.filter((o) => typeof o === "string");
167
+ // On a ZodUnion `.options` is the member SCHEMAS, not strings, so the
168
+ // filter above yields []. Empty is truthy downstream, which is how a union
169
+ // rendered as a <select> with nothing in it. Read the literals instead.
170
+ if (strings.length === 0) {
171
+ const literals = unionStringLiterals(direct);
172
+ return literals.length > 0 ? literals : undefined;
173
+ }
174
+ return strings;
175
+ }
167
176
  const values = schema._def?.values;
168
177
  if (Array.isArray(values))
169
178
  return values.filter((o) => typeof o === "string");
@@ -172,6 +181,26 @@ function enumOptions(schema) {
172
181
  }
173
182
  return undefined;
174
183
  }
184
+ /**
185
+ * String literal values carried by a union's members.
186
+ *
187
+ * `z.literal("a").or(z.literal("b"))` is a genuine choice and yields
188
+ * `["a", "b"]`. `z.string().optional().or(z.literal(""))` — the idiomatic
189
+ * "optional text that also accepts empty" — yields nothing usable, because its
190
+ * only literal is the empty string. That distinction is what decides whether a
191
+ * union field is a select or a text input.
192
+ */
193
+ function unionStringLiterals(members) {
194
+ const out = [];
195
+ for (const member of members) {
196
+ const direct = member?.value;
197
+ const nested = member?._def?.value;
198
+ const value = typeof direct === "string" ? direct : nested;
199
+ if (typeof value === "string" && value !== "")
200
+ out.push(value);
201
+ }
202
+ return out;
203
+ }
175
204
  /** Public: the option list for a select-shaped field, or null. */
176
205
  export function schemaEnumOptions(schema) {
177
206
  const { schema: inner } = unwrapSchema(schema);
@@ -180,7 +209,27 @@ export function schemaEnumOptions(schema) {
180
209
  /** Public: whether a field's type is optional-wrapped (i.e. not required). */
181
210
  export function schemaIsOptional(schema) {
182
211
  const type = typeOf(schema);
183
- return type === "optional" || type === "default" || type === "nullish";
212
+ if (type === "optional" || type === "default" || type === "nullish")
213
+ return true;
214
+ // A union HIDES optionality from the check above: the outer type is "union",
215
+ // so `z.string().optional().or(z.literal(""))` read as required and rendered
216
+ // a `*` on a field the schema is happy to leave empty. If any member is
217
+ // optional, or accepts "", the field is optional.
218
+ if (type === "union") {
219
+ const members = schema.options;
220
+ if (Array.isArray(members)) {
221
+ return members.some((member) => {
222
+ const memberType = typeOf(member);
223
+ if (memberType === "optional" || memberType === "default" || memberType === "nullish") {
224
+ return true;
225
+ }
226
+ const direct = member?.value;
227
+ const nested = member?._def?.value;
228
+ return direct === "" || nested === "";
229
+ });
230
+ }
231
+ }
232
+ return false;
184
233
  }
185
234
  /**
186
235
  * The `.shape` of an object schema, seeing through effects wrappers.
@@ -257,8 +306,19 @@ function fromZodType(type, inner) {
257
306
  case "enum":
258
307
  case "nativeEnum":
259
308
  case "literal":
260
- case "union":
261
309
  return { kind: "select", row: "pair" };
310
+ case "union": {
311
+ // Only a union that actually yields choices is a select. The other
312
+ // common shape, `z.string().optional().or(z.literal(""))`, is an optional
313
+ // TEXT field — rendering it as a select produced a dropdown with zero
314
+ // options, which is unusable and (being marked required) made the whole
315
+ // form unsubmittable. Seen live on /user/addresses/new, where
316
+ // "Address line 2" and "Landmark" were both required empty dropdowns.
317
+ const choices = enumOptions(inner);
318
+ return choices && choices.length > 0
319
+ ? { kind: "select", row: "pair" }
320
+ : { kind: "text", row: "pair" };
321
+ }
262
322
  case "date":
263
323
  return { kind: "date", row: "pair" };
264
324
  case "number":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.41.3",
3
+ "version": "4.41.5",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"