@primocaredentgroup/convex-campaigns-component 0.3.3 → 0.3.4
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/convex/campaigns.ts +1 -0
- package/convex/components/campaigns/domain/types.ts +9 -0
- package/convex/components/campaigns/functions/mutations.ts +30 -2
- package/convex/components/campaigns/functions/queries.ts +68 -0
- package/convex/components/campaigns/ports/patientData.ts +126 -24
- package/convex/playground.ts +16 -0
- package/package.json +4 -2
package/convex/campaigns.ts
CHANGED
|
@@ -83,6 +83,15 @@ export const suppressionReasonValidator = v.union(
|
|
|
83
83
|
v.literal("LOWER_PRIORITY"),
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* params esistenti: monthsSince, daysSince, daysBack, appointmentTypeCodes, patientIds
|
|
88
|
+
* params opzionali anagrafici:
|
|
89
|
+
* genders?: ("M" | "F" | "O")[]
|
|
90
|
+
* minAge?: number -- età minima (anni compiuti, da birthDate)
|
|
91
|
+
* maxAge?: number -- età massima (anni compiuti)
|
|
92
|
+
* residenceCityIds?: string[]
|
|
93
|
+
* birthCityIds?: string[]
|
|
94
|
+
*/
|
|
86
95
|
export const segmentationRulesValidator = v.object({
|
|
87
96
|
segmentType: segmentTypeValidator,
|
|
88
97
|
params: v.optional(v.any()),
|
|
@@ -157,26 +157,40 @@ export const previewAudience = mutation({
|
|
|
157
157
|
args: {
|
|
158
158
|
campaignId: v.id("campaigns"),
|
|
159
159
|
segmentationRules: segmentationRulesValidator,
|
|
160
|
+
sampleSize: v.optional(v.number()),
|
|
160
161
|
},
|
|
161
162
|
handler: async (ctx, args) => {
|
|
162
163
|
await assertAuthorized(ctx, ["Admin", "Marketing"]);
|
|
163
164
|
const campaign = await ctx.db.get(args.campaignId);
|
|
164
165
|
if (!campaign) throw new Error("Campaign not found");
|
|
165
166
|
|
|
167
|
+
const sampleSize = Math.min(Math.max(1, args.sampleSize ?? 10), 50);
|
|
168
|
+
const sample: Array<{
|
|
169
|
+
patientId: string;
|
|
170
|
+
firstName?: string;
|
|
171
|
+
lastName?: string;
|
|
172
|
+
birthDate?: string;
|
|
173
|
+
gender?: string;
|
|
174
|
+
residenceCityName?: string;
|
|
175
|
+
email?: string;
|
|
176
|
+
phone?: string;
|
|
177
|
+
}> = [];
|
|
178
|
+
|
|
166
179
|
let cursor: string | null = null;
|
|
167
180
|
let total = 0;
|
|
168
181
|
let withEmail = 0;
|
|
169
182
|
let withPhone = 0;
|
|
170
183
|
let blacklisted = 0;
|
|
171
|
-
const maxPages =
|
|
184
|
+
const maxPages = 3;
|
|
172
185
|
let pages = 0;
|
|
173
186
|
|
|
174
|
-
while (pages < maxPages) {
|
|
187
|
+
while (pages < maxPages && sample.length < sampleSize) {
|
|
175
188
|
const page = await patientDataPort.queryCandidates(
|
|
176
189
|
ctx,
|
|
177
190
|
{ scopeType: campaign.scopeType, scopeClinicIds: campaign.scopeClinicIds },
|
|
178
191
|
args.segmentationRules as any,
|
|
179
192
|
cursor,
|
|
193
|
+
{ includeAnagrafica: true },
|
|
180
194
|
);
|
|
181
195
|
for (const p of page.patients) {
|
|
182
196
|
total += 1;
|
|
@@ -184,6 +198,19 @@ export const previewAudience = mutation({
|
|
|
184
198
|
if (p.email && consent.email) withEmail += 1;
|
|
185
199
|
if (p.phone && consent.phone) withPhone += 1;
|
|
186
200
|
if (consent.blacklisted) blacklisted += 1;
|
|
201
|
+
|
|
202
|
+
if (sample.length < sampleSize) {
|
|
203
|
+
sample.push({
|
|
204
|
+
patientId: p.patientId,
|
|
205
|
+
firstName: p.firstName,
|
|
206
|
+
lastName: p.lastName,
|
|
207
|
+
birthDate: p.birthDate,
|
|
208
|
+
gender: p.gender,
|
|
209
|
+
residenceCityName: p.residenceCityName,
|
|
210
|
+
email: p.email,
|
|
211
|
+
phone: p.phone,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
187
214
|
}
|
|
188
215
|
cursor = page.nextCursor;
|
|
189
216
|
pages += 1;
|
|
@@ -195,6 +222,7 @@ export const previewAudience = mutation({
|
|
|
195
222
|
warnings: cursor
|
|
196
223
|
? ["Preview limited to first pages for performance. Build snapshot for full results."]
|
|
197
224
|
: [],
|
|
225
|
+
sample,
|
|
198
226
|
};
|
|
199
227
|
},
|
|
200
228
|
});
|
|
@@ -150,6 +150,74 @@ export const getSnapshot = query({
|
|
|
150
150
|
},
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
export const getSnapshotMemberSample = query({
|
|
154
|
+
args: {
|
|
155
|
+
snapshotId: v.id("audience_snapshots"),
|
|
156
|
+
limit: v.optional(v.number()),
|
|
157
|
+
},
|
|
158
|
+
handler: async (ctx, args) => {
|
|
159
|
+
try {
|
|
160
|
+
await assertAuthorized(ctx, ["Admin", "Marketing"]);
|
|
161
|
+
const limit = Math.min(Math.max(1, args.limit ?? 10), 100);
|
|
162
|
+
|
|
163
|
+
let members: any[];
|
|
164
|
+
try {
|
|
165
|
+
members = (await ctx.db
|
|
166
|
+
.query("audience_members")
|
|
167
|
+
.withIndex("by_snapshot", (q: any) => q.eq("snapshotId", args.snapshotId))
|
|
168
|
+
.collect()).slice(0, limit);
|
|
169
|
+
} catch {
|
|
170
|
+
members = (await ctx.db
|
|
171
|
+
.query("audience_members")
|
|
172
|
+
.filter((q: any) => q.eq(q.field("snapshotId"), args.snapshotId))
|
|
173
|
+
.collect()).slice(0, limit);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const sample: Array<{
|
|
177
|
+
patientId: string;
|
|
178
|
+
firstName?: string;
|
|
179
|
+
lastName?: string;
|
|
180
|
+
birthDate?: string;
|
|
181
|
+
gender?: string;
|
|
182
|
+
residenceCityName?: string;
|
|
183
|
+
email?: string;
|
|
184
|
+
phone?: string;
|
|
185
|
+
}> = [];
|
|
186
|
+
|
|
187
|
+
for (const m of members) {
|
|
188
|
+
const patient = await ctx.db.get(m.patientId);
|
|
189
|
+
if (!patient) continue;
|
|
190
|
+
const p = patient as any;
|
|
191
|
+
let residenceCityName: string | undefined;
|
|
192
|
+
if (p.residenceCityId) {
|
|
193
|
+
try {
|
|
194
|
+
const city = await ctx.db.get(p.residenceCityId);
|
|
195
|
+
residenceCityName = (city as any)?.name;
|
|
196
|
+
} catch {
|
|
197
|
+
// Host may not have cities table
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
sample.push({
|
|
201
|
+
patientId: m.patientId,
|
|
202
|
+
firstName: p.firstName,
|
|
203
|
+
lastName: p.lastName,
|
|
204
|
+
birthDate: p.birthDate,
|
|
205
|
+
gender: p.gender,
|
|
206
|
+
residenceCityName,
|
|
207
|
+
email: m.contactRefs?.email ?? p.email,
|
|
208
|
+
phone: m.contactRefs?.phone ?? p.phone ?? p.mobilePhone,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { sample };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (!isRecoverableQueryError(error)) throw error;
|
|
215
|
+
console.error("[campaigns.getSnapshotMemberSample] recoverable error:", error);
|
|
216
|
+
return { sample: [] };
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
153
221
|
export const getCampaignReport = query({
|
|
154
222
|
args: {
|
|
155
223
|
campaignId: v.id("campaigns"),
|
|
@@ -16,6 +16,11 @@ export type CandidatePatient = {
|
|
|
16
16
|
clinicId?: Id<"clinics">;
|
|
17
17
|
email?: string;
|
|
18
18
|
phone?: string;
|
|
19
|
+
firstName?: string;
|
|
20
|
+
lastName?: string;
|
|
21
|
+
birthDate?: string;
|
|
22
|
+
gender?: string;
|
|
23
|
+
residenceCityName?: string;
|
|
19
24
|
};
|
|
20
25
|
|
|
21
26
|
const DEFAULT_PAGE_SIZE = 200;
|
|
@@ -23,6 +28,98 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
23
28
|
|
|
24
29
|
type AppointmentDoc = Doc<"appointments">;
|
|
25
30
|
|
|
31
|
+
/** Calcola età in anni da birthDate (formato "YYYY-MM-DD" o simile) */
|
|
32
|
+
function ageFromBirthDate(birthDate: string | undefined): number | null {
|
|
33
|
+
if (!birthDate) return null;
|
|
34
|
+
const parts = birthDate.split("-").map(Number);
|
|
35
|
+
if (parts.length < 2) return null;
|
|
36
|
+
const [y, m = 1, d = 1] = parts;
|
|
37
|
+
const birth = new Date(y, m - 1, d).getTime();
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
return Math.floor((now - birth) / (365.25 * DAY_MS));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Verifica se il paziente passa i filtri demografici (genere, età, città).
|
|
44
|
+
* Se un filtro è assente, non viene applicato.
|
|
45
|
+
*/
|
|
46
|
+
export function matchesDemographicFilters(
|
|
47
|
+
patient: { gender?: string; birthDate?: string; residenceCityId?: any; birthCityId?: any },
|
|
48
|
+
params: any,
|
|
49
|
+
): boolean {
|
|
50
|
+
if (!params) return true;
|
|
51
|
+
|
|
52
|
+
const genders = Array.isArray(params.genders) ? params.genders as string[] : [];
|
|
53
|
+
if (genders.length > 0 && patient.gender) {
|
|
54
|
+
if (!genders.includes(patient.gender)) return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const minAge = typeof params.minAge === "number" ? params.minAge : undefined;
|
|
58
|
+
const maxAge = typeof params.maxAge === "number" ? params.maxAge : undefined;
|
|
59
|
+
if (minAge != null || maxAge != null) {
|
|
60
|
+
const age = ageFromBirthDate(patient.birthDate);
|
|
61
|
+
if (age === null) return false;
|
|
62
|
+
if (minAge != null && age < minAge) return false;
|
|
63
|
+
if (maxAge != null && age > maxAge) return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const residenceCityIds = Array.isArray(params.residenceCityIds)
|
|
67
|
+
? params.residenceCityIds.map(String)
|
|
68
|
+
: [];
|
|
69
|
+
if (residenceCityIds.length > 0 && patient.residenceCityId) {
|
|
70
|
+
if (!residenceCityIds.includes(String(patient.residenceCityId))) return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const birthCityIds = Array.isArray(params.birthCityIds) ? params.birthCityIds.map(String) : [];
|
|
74
|
+
if (birthCityIds.length > 0 && patient.birthCityId) {
|
|
75
|
+
if (!birthCityIds.includes(String(patient.birthCityId))) return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resolveResidenceCityName(
|
|
82
|
+
ctx: QueryCtx | MutationCtx,
|
|
83
|
+
residenceCityId: any,
|
|
84
|
+
): Promise<string | undefined> {
|
|
85
|
+
if (!residenceCityId) return undefined;
|
|
86
|
+
try {
|
|
87
|
+
const city = await ctx.db.get(residenceCityId);
|
|
88
|
+
return (city as any)?.name;
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildCandidatePatient(
|
|
95
|
+
patient: any,
|
|
96
|
+
clinicId: Id<"clinics"> | undefined,
|
|
97
|
+
residenceCityName?: string,
|
|
98
|
+
): CandidatePatient {
|
|
99
|
+
return {
|
|
100
|
+
patientId: patient._id,
|
|
101
|
+
clinicId,
|
|
102
|
+
email: patient.email,
|
|
103
|
+
phone: patient.phone ?? patient.mobilePhone,
|
|
104
|
+
firstName: patient.firstName,
|
|
105
|
+
lastName: patient.lastName,
|
|
106
|
+
birthDate: patient.birthDate,
|
|
107
|
+
gender: patient.gender,
|
|
108
|
+
residenceCityName,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function buildCandidateWithAnagrafica(
|
|
113
|
+
ctx: QueryCtx | MutationCtx,
|
|
114
|
+
patient: any,
|
|
115
|
+
clinicId: Id<"clinics"> | undefined,
|
|
116
|
+
): Promise<CandidatePatient> {
|
|
117
|
+
const residenceCityName = patient.residenceCityId
|
|
118
|
+
? await resolveResidenceCityName(ctx, patient.residenceCityId)
|
|
119
|
+
: undefined;
|
|
120
|
+
return buildCandidatePatient(patient, clinicId, residenceCityName);
|
|
121
|
+
}
|
|
122
|
+
|
|
26
123
|
async function deriveClinicIdFromAppointments(
|
|
27
124
|
ctx: QueryCtx | MutationCtx,
|
|
28
125
|
patient: Doc<"patients">,
|
|
@@ -73,6 +170,7 @@ async function queryNoShowCandidates(
|
|
|
73
170
|
campaignScope: CampaignScope,
|
|
74
171
|
params: any,
|
|
75
172
|
paginationCursor: string | null,
|
|
173
|
+
options?: { includeAnagrafica?: boolean },
|
|
76
174
|
): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
|
|
77
175
|
const daysBack = typeof params?.daysBack === "number" ? params.daysBack : 90;
|
|
78
176
|
const cutoff = Date.now() - daysBack * DAY_MS;
|
|
@@ -96,15 +194,15 @@ async function queryNoShowCandidates(
|
|
|
96
194
|
isInScope(campaignScope, appointment.clinicId),
|
|
97
195
|
);
|
|
98
196
|
if (!hasNoShow) continue;
|
|
197
|
+
if (!matchesDemographicFilters(patient as any, params)) continue;
|
|
99
198
|
|
|
100
199
|
const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
|
|
101
200
|
if (campaignScope.scopeType !== "HQ" && !clinicId) continue;
|
|
102
|
-
patients.push(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
});
|
|
201
|
+
patients.push(
|
|
202
|
+
options?.includeAnagrafica
|
|
203
|
+
? await buildCandidateWithAnagrafica(ctx, patient as any, clinicId)
|
|
204
|
+
: buildCandidatePatient(patient as any, clinicId),
|
|
205
|
+
);
|
|
108
206
|
}
|
|
109
207
|
|
|
110
208
|
return {
|
|
@@ -127,6 +225,7 @@ async function queryRecallOrQuoteFollowupCandidates(
|
|
|
127
225
|
campaignScope: CampaignScope,
|
|
128
226
|
segmentationRules: SegmentationRules,
|
|
129
227
|
paginationCursor: string | null,
|
|
228
|
+
options?: { includeAnagrafica?: boolean },
|
|
130
229
|
): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
|
|
131
230
|
const now = Date.now();
|
|
132
231
|
const page = await ctx.db
|
|
@@ -188,13 +287,13 @@ async function queryRecallOrQuoteFollowupCandidates(
|
|
|
188
287
|
? lastCompleted.appointmentDate <= cutoffRecall
|
|
189
288
|
: lastCompleted.appointmentDate <= cutoffQuote;
|
|
190
289
|
if (!qualifies) continue;
|
|
290
|
+
if (!matchesDemographicFilters(patient as any, segmentationRules.params)) continue;
|
|
191
291
|
|
|
192
|
-
patients.push(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
});
|
|
292
|
+
patients.push(
|
|
293
|
+
options?.includeAnagrafica
|
|
294
|
+
? await buildCandidateWithAnagrafica(ctx, patient as any, clinicId)
|
|
295
|
+
: buildCandidatePatient(patient as any, clinicId),
|
|
296
|
+
);
|
|
198
297
|
}
|
|
199
298
|
|
|
200
299
|
return { patients, nextCursor: page.isDone ? null : page.continueCursor };
|
|
@@ -205,6 +304,7 @@ export async function queryCandidates(
|
|
|
205
304
|
campaignScope: CampaignScope,
|
|
206
305
|
segmentationRules: SegmentationRules,
|
|
207
306
|
paginationCursor: string | null,
|
|
307
|
+
options?: { includeAnagrafica?: boolean },
|
|
208
308
|
): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
|
|
209
309
|
if (segmentationRules.segmentType === "CUSTOM_IDS") {
|
|
210
310
|
const patientIds = Array.isArray(segmentationRules.params?.patientIds)
|
|
@@ -217,14 +317,14 @@ export async function queryCandidates(
|
|
|
217
317
|
for (const patientId of chunk) {
|
|
218
318
|
const patient = await ctx.db.get(patientId);
|
|
219
319
|
if (!patient || patient.status === "archived") continue;
|
|
320
|
+
if (!matchesDemographicFilters(patient as any, segmentationRules.params)) continue;
|
|
220
321
|
const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
|
|
221
322
|
if (campaignScope.scopeType !== "HQ" && !clinicId) continue;
|
|
222
|
-
patients.push(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
});
|
|
323
|
+
patients.push(
|
|
324
|
+
options?.includeAnagrafica
|
|
325
|
+
? await buildCandidateWithAnagrafica(ctx, patient as any, clinicId)
|
|
326
|
+
: buildCandidatePatient(patient as any, clinicId),
|
|
327
|
+
);
|
|
228
328
|
}
|
|
229
329
|
|
|
230
330
|
const next = offset + chunk.length;
|
|
@@ -237,6 +337,7 @@ export async function queryCandidates(
|
|
|
237
337
|
campaignScope,
|
|
238
338
|
segmentationRules.params,
|
|
239
339
|
paginationCursor,
|
|
340
|
+
options,
|
|
240
341
|
);
|
|
241
342
|
}
|
|
242
343
|
|
|
@@ -249,6 +350,7 @@ export async function queryCandidates(
|
|
|
249
350
|
campaignScope,
|
|
250
351
|
segmentationRules,
|
|
251
352
|
paginationCursor,
|
|
353
|
+
options,
|
|
252
354
|
);
|
|
253
355
|
}
|
|
254
356
|
|
|
@@ -259,14 +361,14 @@ export async function queryCandidates(
|
|
|
259
361
|
|
|
260
362
|
const patients: CandidatePatient[] = [];
|
|
261
363
|
for (const patient of page.page) {
|
|
364
|
+
if (!matchesDemographicFilters(patient as any, segmentationRules.params)) continue;
|
|
262
365
|
const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
|
|
263
366
|
if (campaignScope.scopeType !== "HQ" && !clinicId) continue;
|
|
264
|
-
patients.push(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
});
|
|
367
|
+
patients.push(
|
|
368
|
+
options?.includeAnagrafica
|
|
369
|
+
? await buildCandidateWithAnagrafica(ctx, patient as any, clinicId)
|
|
370
|
+
: buildCandidatePatient(patient as any, clinicId),
|
|
371
|
+
);
|
|
270
372
|
}
|
|
271
373
|
|
|
272
374
|
return { patients, nextCursor: page.isDone ? null : page.continueCursor };
|
package/convex/playground.ts
CHANGED
|
@@ -11,6 +11,9 @@ import { components } from "./_generated/api";
|
|
|
11
11
|
|
|
12
12
|
const c = components as {
|
|
13
13
|
campaigns?: {
|
|
14
|
+
campaignsQueries?: {
|
|
15
|
+
getSnapshotMemberSample: (args: { snapshotId: string; limit?: number }) => Promise<unknown>;
|
|
16
|
+
};
|
|
14
17
|
campaignsPlayground?: {
|
|
15
18
|
listCampaignsV2: (args: { orgId: string; status?: "draft" | "ready" | "archived" }) => Promise<unknown>;
|
|
16
19
|
getPlaygroundBootstrap: (args: { orgId: string }) => Promise<unknown>;
|
|
@@ -79,6 +82,19 @@ export const getAudiencePage = query({
|
|
|
79
82
|
},
|
|
80
83
|
});
|
|
81
84
|
|
|
85
|
+
export const getSnapshotMemberSample = query({
|
|
86
|
+
args: {
|
|
87
|
+
snapshotId: v.string(),
|
|
88
|
+
limit: v.optional(v.number()),
|
|
89
|
+
},
|
|
90
|
+
handler: async (ctx, args) => {
|
|
91
|
+
return await ctx.runQuery(c.campaigns!.campaignsQueries!.getSnapshotMemberSample, {
|
|
92
|
+
snapshotId: args.snapshotId,
|
|
93
|
+
limit: args.limit,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
82
98
|
export const getPatientCampaignMemberships = query({
|
|
83
99
|
args: { orgId: v.string(), patientId: v.string() },
|
|
84
100
|
handler: async (ctx, args) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primocaredentgroup/convex-campaigns-component",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Convex Campaigns backend component for PrimoCore",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"sync:from-repo": "node ./scripts/sync-from-repo.mjs",
|
|
17
17
|
"prepack": "npm run sync:from-repo",
|
|
18
18
|
"test": "tsx --test tests/**/*.test.ts",
|
|
19
|
-
"smoke": "node scripts/smoke-test.mjs"
|
|
19
|
+
"smoke": "node scripts/smoke-test.mjs",
|
|
20
|
+
"version:patch": "npm version patch --no-git-tag-version",
|
|
21
|
+
"publish:component": "npm run version:patch && npm publish"
|
|
20
22
|
},
|
|
21
23
|
"peerDependencies": {
|
|
22
24
|
"convex": "^1.14.0"
|