@volchoklv/newsletter-kit 1.0.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.
@@ -0,0 +1,215 @@
1
+ import { S as StorageAdapter } from '../../types-BmajlhNp.js';
2
+ export { e as SubscribeInput, h as Subscriber, i as SubscriptionStatus } from '../../types-BmajlhNp.js';
3
+
4
+ /**
5
+ * Prisma schema required for this adapter:
6
+ *
7
+ * **PostgreSQL / Neon / MySQL:**
8
+ * ```prisma
9
+ * model NewsletterSubscriber {
10
+ * id String @id @default(cuid())
11
+ * email String @unique
12
+ * status String @default("pending") // pending, confirmed, unsubscribed
13
+ * token String? @unique
14
+ * source String?
15
+ * tags String[] @default([])
16
+ * metadata Json?
17
+ * consentIp String?
18
+ * consentAt DateTime?
19
+ * confirmedAt DateTime?
20
+ * unsubscribedAt DateTime?
21
+ * createdAt DateTime @default(now())
22
+ * updatedAt DateTime @updatedAt
23
+ *
24
+ * @@index([status])
25
+ * @@index([source])
26
+ * }
27
+ * ```
28
+ *
29
+ * **MongoDB:**
30
+ * ```prisma
31
+ * model NewsletterSubscriber {
32
+ * id String @id @default(auto()) @map("_id") @db.ObjectId
33
+ * email String @unique
34
+ * status String @default("pending") // pending, confirmed, unsubscribed
35
+ * token String? @unique
36
+ * source String?
37
+ * tags String[] @default([])
38
+ * metadata Json?
39
+ * consentIp String?
40
+ * consentAt DateTime?
41
+ * confirmedAt DateTime?
42
+ * unsubscribedAt DateTime?
43
+ * createdAt DateTime @default(now())
44
+ * updatedAt DateTime @updatedAt
45
+ *
46
+ * @@index([status])
47
+ * @@index([source])
48
+ * }
49
+ * ```
50
+ */
51
+ interface PrismaClient {
52
+ newsletterSubscriber: {
53
+ upsert: (args: unknown) => Promise<unknown>;
54
+ findUnique: (args: unknown) => Promise<unknown>;
55
+ findFirst: (args: unknown) => Promise<unknown>;
56
+ findMany: (args: unknown) => Promise<unknown[]>;
57
+ update: (args: unknown) => Promise<unknown>;
58
+ delete: (args: unknown) => Promise<unknown>;
59
+ count: (args?: unknown) => Promise<number>;
60
+ };
61
+ }
62
+ interface PrismaAdapterConfig {
63
+ /** Your Prisma client instance */
64
+ prisma: PrismaClient;
65
+ /** Model name if different from 'newsletterSubscriber' */
66
+ modelName?: string;
67
+ }
68
+ /**
69
+ * Storage adapter for Prisma ORM
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * import { createPrismaAdapter } from '@volchok/newsletter-kit/adapters/storage';
74
+ * import { prisma } from '@/lib/prisma';
75
+ *
76
+ * const storageAdapter = createPrismaAdapter({ prisma });
77
+ * ```
78
+ */
79
+ declare function createPrismaAdapter(config: PrismaAdapterConfig): StorageAdapter;
80
+
81
+ /**
82
+ * Supabase table schema (SQL):
83
+ *
84
+ * ```sql
85
+ * CREATE TABLE newsletter_subscribers (
86
+ * id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
87
+ * email TEXT UNIQUE NOT NULL,
88
+ * status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'unsubscribed')),
89
+ * token TEXT UNIQUE,
90
+ * source TEXT,
91
+ * tags TEXT[] DEFAULT '{}',
92
+ * metadata JSONB,
93
+ * consent_ip TEXT,
94
+ * consent_at TIMESTAMPTZ,
95
+ * confirmed_at TIMESTAMPTZ,
96
+ * unsubscribed_at TIMESTAMPTZ,
97
+ * created_at TIMESTAMPTZ DEFAULT NOW(),
98
+ * updated_at TIMESTAMPTZ DEFAULT NOW()
99
+ * );
100
+ *
101
+ * -- Indexes
102
+ * CREATE INDEX idx_newsletter_subscribers_status ON newsletter_subscribers(status);
103
+ * CREATE INDEX idx_newsletter_subscribers_source ON newsletter_subscribers(source);
104
+ * CREATE INDEX idx_newsletter_subscribers_token ON newsletter_subscribers(token);
105
+ *
106
+ * -- Updated at trigger
107
+ * CREATE OR REPLACE FUNCTION update_updated_at()
108
+ * RETURNS TRIGGER AS $$
109
+ * BEGIN
110
+ * NEW.updated_at = NOW();
111
+ * RETURN NEW;
112
+ * END;
113
+ * $$ LANGUAGE plpgsql;
114
+ *
115
+ * CREATE TRIGGER newsletter_subscribers_updated_at
116
+ * BEFORE UPDATE ON newsletter_subscribers
117
+ * FOR EACH ROW
118
+ * EXECUTE FUNCTION update_updated_at();
119
+ * ```
120
+ */
121
+ interface SupabaseClient {
122
+ from: (table: string) => {
123
+ upsert: (data: unknown, options?: unknown) => Promise<{
124
+ data: unknown;
125
+ error: unknown;
126
+ }>;
127
+ select: (columns?: string) => {
128
+ eq: (column: string, value: unknown) => {
129
+ single: () => Promise<{
130
+ data: unknown;
131
+ error: unknown;
132
+ }>;
133
+ range: (from: number, to: number) => Promise<{
134
+ data: unknown[];
135
+ error: unknown;
136
+ }>;
137
+ };
138
+ contains: (column: string, value: unknown) => {
139
+ range: (from: number, to: number) => Promise<{
140
+ data: unknown[];
141
+ error: unknown;
142
+ }>;
143
+ };
144
+ order: (column: string, options?: unknown) => {
145
+ range: (from: number, to: number) => Promise<{
146
+ data: unknown[];
147
+ error: unknown;
148
+ }>;
149
+ };
150
+ };
151
+ update: (data: unknown) => {
152
+ eq: (column: string, value: unknown) => Promise<{
153
+ data: unknown;
154
+ error: unknown;
155
+ }>;
156
+ };
157
+ delete: () => {
158
+ eq: (column: string, value: unknown) => Promise<{
159
+ data: unknown;
160
+ error: unknown;
161
+ }>;
162
+ };
163
+ };
164
+ }
165
+ interface SupabaseAdapterConfig {
166
+ /** Your Supabase client instance */
167
+ supabase: SupabaseClient;
168
+ /** Table name if different from 'newsletter_subscribers' */
169
+ tableName?: string;
170
+ }
171
+ /**
172
+ * Storage adapter for Supabase
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * import { createSupabaseAdapter } from '@volchok/newsletter-kit/adapters/storage';
177
+ * import { createClient } from '@supabase/supabase-js';
178
+ *
179
+ * const supabase = createClient(
180
+ * process.env.SUPABASE_URL!,
181
+ * process.env.SUPABASE_SERVICE_KEY!
182
+ * );
183
+ *
184
+ * const storageAdapter = createSupabaseAdapter({ supabase });
185
+ * ```
186
+ */
187
+ declare function createSupabaseAdapter(config: SupabaseAdapterConfig): StorageAdapter;
188
+
189
+ /**
190
+ * In-memory storage adapter
191
+ *
192
+ * Useful for:
193
+ * - Development and testing
194
+ * - Simple sites that don't need persistence
195
+ * - When you only want to send emails without storing subscribers
196
+ *
197
+ * WARNING: Data is lost when the server restarts!
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * import { createMemoryAdapter } from '@volchok/newsletter-kit/adapters/storage';
202
+ *
203
+ * const storageAdapter = createMemoryAdapter();
204
+ * ```
205
+ */
206
+ declare function createMemoryAdapter(): StorageAdapter;
207
+ /**
208
+ * No-op storage adapter
209
+ *
210
+ * Use when you don't want to store subscribers at all,
211
+ * only send emails (e.g., when using Mailchimp which handles storage)
212
+ */
213
+ declare function createNoopAdapter(): StorageAdapter;
214
+
215
+ export { StorageAdapter, createMemoryAdapter, createNoopAdapter, createPrismaAdapter, createSupabaseAdapter };
@@ -0,0 +1,415 @@
1
+ "use client";
2
+
3
+ // src/utils/crypto.ts
4
+ import { randomBytes } from "crypto";
5
+ function generateToken(length = 32) {
6
+ return randomBytes(length).toString("hex");
7
+ }
8
+
9
+ // src/adapters/storage/prisma.ts
10
+ function mapToSubscriber(record) {
11
+ return {
12
+ id: record.id,
13
+ email: record.email,
14
+ status: record.status,
15
+ source: record.source,
16
+ tags: record.tags,
17
+ metadata: record.metadata,
18
+ consentIp: record.consentIp,
19
+ consentAt: record.consentAt ? new Date(record.consentAt) : void 0,
20
+ confirmedAt: record.confirmedAt ? new Date(record.confirmedAt) : void 0,
21
+ unsubscribedAt: record.unsubscribedAt ? new Date(record.unsubscribedAt) : void 0,
22
+ createdAt: new Date(record.createdAt),
23
+ updatedAt: new Date(record.updatedAt)
24
+ };
25
+ }
26
+ function createPrismaAdapter(config) {
27
+ const { prisma } = config;
28
+ const model = prisma.newsletterSubscriber;
29
+ return {
30
+ async createSubscriber(input, token) {
31
+ const confirmToken = token || generateToken();
32
+ const record = await model.upsert({
33
+ where: { email: input.email.toLowerCase() },
34
+ update: {
35
+ token: confirmToken,
36
+ source: input.source,
37
+ tags: input.tags || [],
38
+ metadata: input.metadata,
39
+ consentIp: input.ip,
40
+ consentAt: /* @__PURE__ */ new Date(),
41
+ // Reset to pending if resubscribing
42
+ status: "pending",
43
+ unsubscribedAt: null
44
+ },
45
+ create: {
46
+ email: input.email.toLowerCase(),
47
+ token: confirmToken,
48
+ status: "pending",
49
+ source: input.source,
50
+ tags: input.tags || [],
51
+ metadata: input.metadata,
52
+ consentIp: input.ip,
53
+ consentAt: /* @__PURE__ */ new Date()
54
+ }
55
+ });
56
+ return mapToSubscriber(record);
57
+ },
58
+ async getSubscriberByEmail(email) {
59
+ const record = await model.findUnique({
60
+ where: { email: email.toLowerCase() }
61
+ });
62
+ return record ? mapToSubscriber(record) : null;
63
+ },
64
+ async getSubscriberByToken(token) {
65
+ const record = await model.findFirst({
66
+ where: { token }
67
+ });
68
+ return record ? mapToSubscriber(record) : null;
69
+ },
70
+ async confirmSubscriber(token) {
71
+ try {
72
+ const record = await model.update({
73
+ where: { token },
74
+ data: {
75
+ status: "confirmed",
76
+ confirmedAt: /* @__PURE__ */ new Date(),
77
+ token: null
78
+ // Clear token after use
79
+ }
80
+ });
81
+ return mapToSubscriber(record);
82
+ } catch {
83
+ return null;
84
+ }
85
+ },
86
+ async unsubscribe(email) {
87
+ try {
88
+ await model.update({
89
+ where: { email: email.toLowerCase() },
90
+ data: {
91
+ status: "unsubscribed",
92
+ unsubscribedAt: /* @__PURE__ */ new Date()
93
+ }
94
+ });
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ },
100
+ async listSubscribers(options) {
101
+ const where = {};
102
+ if (options?.status) {
103
+ where.status = options.status;
104
+ }
105
+ if (options?.source) {
106
+ where.source = options.source;
107
+ }
108
+ if (options?.tags?.length) {
109
+ where.tags = { hasSome: options.tags };
110
+ }
111
+ const [records, total] = await Promise.all([
112
+ model.findMany({
113
+ where,
114
+ take: options?.limit || 100,
115
+ skip: options?.offset || 0,
116
+ orderBy: { createdAt: "desc" }
117
+ }),
118
+ model.count({ where })
119
+ ]);
120
+ return {
121
+ subscribers: records.map((r) => mapToSubscriber(r)),
122
+ total
123
+ };
124
+ },
125
+ async deleteSubscriber(email) {
126
+ try {
127
+ await model.delete({
128
+ where: { email: email.toLowerCase() }
129
+ });
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ },
135
+ async updateSubscriber(email, data) {
136
+ try {
137
+ const record = await model.update({
138
+ where: { email: email.toLowerCase() },
139
+ data: {
140
+ ...data.source !== void 0 && { source: data.source },
141
+ ...data.tags !== void 0 && { tags: data.tags },
142
+ ...data.metadata !== void 0 && { metadata: data.metadata }
143
+ }
144
+ });
145
+ return mapToSubscriber(record);
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+ };
151
+ }
152
+
153
+ // src/adapters/storage/supabase.ts
154
+ function mapToSubscriber2(record) {
155
+ return {
156
+ id: record.id,
157
+ email: record.email,
158
+ status: record.status,
159
+ source: record.source,
160
+ tags: record.tags,
161
+ metadata: record.metadata,
162
+ consentIp: record.consent_ip,
163
+ consentAt: record.consent_at ? new Date(record.consent_at) : void 0,
164
+ confirmedAt: record.confirmed_at ? new Date(record.confirmed_at) : void 0,
165
+ unsubscribedAt: record.unsubscribed_at ? new Date(record.unsubscribed_at) : void 0,
166
+ createdAt: new Date(record.created_at),
167
+ updatedAt: new Date(record.updated_at)
168
+ };
169
+ }
170
+ function createSupabaseAdapter(config) {
171
+ const { supabase, tableName = "newsletter_subscribers" } = config;
172
+ return {
173
+ async createSubscriber(input, token) {
174
+ const confirmToken = token || generateToken();
175
+ const email = input.email.toLowerCase();
176
+ const { data, error } = await supabase.from(tableName).upsert(
177
+ {
178
+ email,
179
+ token: confirmToken,
180
+ status: "pending",
181
+ source: input.source,
182
+ tags: input.tags || [],
183
+ metadata: input.metadata,
184
+ consent_ip: input.ip,
185
+ consent_at: (/* @__PURE__ */ new Date()).toISOString(),
186
+ unsubscribed_at: null
187
+ },
188
+ { onConflict: "email" }
189
+ );
190
+ if (error) throw error;
191
+ const { data: record } = await supabase.from(tableName).select().eq("email", email).single();
192
+ return mapToSubscriber2(record);
193
+ },
194
+ async getSubscriberByEmail(email) {
195
+ const { data, error } = await supabase.from(tableName).select().eq("email", email.toLowerCase()).single();
196
+ if (error || !data) return null;
197
+ return mapToSubscriber2(data);
198
+ },
199
+ async getSubscriberByToken(token) {
200
+ const { data, error } = await supabase.from(tableName).select().eq("token", token).single();
201
+ if (error || !data) return null;
202
+ return mapToSubscriber2(data);
203
+ },
204
+ async confirmSubscriber(token) {
205
+ const { error } = await supabase.from(tableName).update({
206
+ status: "confirmed",
207
+ confirmed_at: (/* @__PURE__ */ new Date()).toISOString(),
208
+ token: null
209
+ }).eq("token", token);
210
+ if (error) return null;
211
+ const { data } = await supabase.from(tableName).select().eq("token", null).single();
212
+ return data ? mapToSubscriber2(data) : null;
213
+ },
214
+ async unsubscribe(email) {
215
+ const { error } = await supabase.from(tableName).update({
216
+ status: "unsubscribed",
217
+ unsubscribed_at: (/* @__PURE__ */ new Date()).toISOString()
218
+ }).eq("email", email.toLowerCase());
219
+ return !error;
220
+ },
221
+ async listSubscribers(options) {
222
+ const limit = options?.limit || 100;
223
+ const offset = options?.offset || 0;
224
+ let query = supabase.from(tableName).select();
225
+ if (options?.status) {
226
+ query = query.eq("status", options.status);
227
+ }
228
+ if (options?.source) {
229
+ query = query.eq("source", options.source);
230
+ }
231
+ if (options?.tags?.length) {
232
+ query = query.contains("tags", options.tags);
233
+ }
234
+ const { data, error } = await query.order("created_at", { ascending: false }).range(offset, offset + limit - 1);
235
+ if (error) throw error;
236
+ return {
237
+ subscribers: (data || []).map((r) => mapToSubscriber2(r)),
238
+ total: (data || []).length
239
+ // Note: Supabase doesn't return total count easily
240
+ };
241
+ },
242
+ async deleteSubscriber(email) {
243
+ const { error } = await supabase.from(tableName).delete().eq("email", email.toLowerCase());
244
+ return !error;
245
+ },
246
+ async updateSubscriber(email, data) {
247
+ const updateData = {};
248
+ if (data.source !== void 0) updateData.source = data.source;
249
+ if (data.tags !== void 0) updateData.tags = data.tags;
250
+ if (data.metadata !== void 0) updateData.metadata = data.metadata;
251
+ const { error } = await supabase.from(tableName).update(updateData).eq("email", email.toLowerCase());
252
+ if (error) return null;
253
+ return this.getSubscriberByEmail(email);
254
+ }
255
+ };
256
+ }
257
+
258
+ // src/adapters/storage/memory.ts
259
+ function createMemoryAdapter() {
260
+ const subscribers = /* @__PURE__ */ new Map();
261
+ const tokenIndex = /* @__PURE__ */ new Map();
262
+ return {
263
+ async createSubscriber(input, token) {
264
+ const email = input.email.toLowerCase();
265
+ const confirmToken = token || generateToken();
266
+ const now = /* @__PURE__ */ new Date();
267
+ const existing = subscribers.get(email);
268
+ if (existing?.id) {
269
+ for (const [t, e] of tokenIndex.entries()) {
270
+ if (e === email) {
271
+ tokenIndex.delete(t);
272
+ break;
273
+ }
274
+ }
275
+ }
276
+ const subscriber = {
277
+ id: existing?.id || crypto.randomUUID(),
278
+ email,
279
+ status: "pending",
280
+ source: input.source,
281
+ tags: input.tags || [],
282
+ metadata: input.metadata,
283
+ consentIp: input.ip,
284
+ consentAt: now,
285
+ confirmedAt: void 0,
286
+ unsubscribedAt: void 0,
287
+ createdAt: existing?.createdAt || now,
288
+ updatedAt: now
289
+ };
290
+ subscribers.set(email, subscriber);
291
+ tokenIndex.set(confirmToken, email);
292
+ return { ...subscriber, id: confirmToken };
293
+ },
294
+ async getSubscriberByEmail(email) {
295
+ return subscribers.get(email.toLowerCase()) || null;
296
+ },
297
+ async getSubscriberByToken(token) {
298
+ const email = tokenIndex.get(token);
299
+ if (!email) return null;
300
+ return subscribers.get(email) || null;
301
+ },
302
+ async confirmSubscriber(token) {
303
+ const email = tokenIndex.get(token);
304
+ if (!email) return null;
305
+ const subscriber = subscribers.get(email);
306
+ if (!subscriber) return null;
307
+ const updated = {
308
+ ...subscriber,
309
+ status: "confirmed",
310
+ confirmedAt: /* @__PURE__ */ new Date(),
311
+ updatedAt: /* @__PURE__ */ new Date()
312
+ };
313
+ subscribers.set(email, updated);
314
+ tokenIndex.delete(token);
315
+ return updated;
316
+ },
317
+ async unsubscribe(email) {
318
+ const subscriber = subscribers.get(email.toLowerCase());
319
+ if (!subscriber) return false;
320
+ const updated = {
321
+ ...subscriber,
322
+ status: "unsubscribed",
323
+ unsubscribedAt: /* @__PURE__ */ new Date(),
324
+ updatedAt: /* @__PURE__ */ new Date()
325
+ };
326
+ subscribers.set(email.toLowerCase(), updated);
327
+ return true;
328
+ },
329
+ async listSubscribers(options) {
330
+ let results = Array.from(subscribers.values());
331
+ if (options?.status) {
332
+ results = results.filter((s) => s.status === options.status);
333
+ }
334
+ if (options?.source) {
335
+ results = results.filter((s) => s.source === options.source);
336
+ }
337
+ if (options?.tags?.length) {
338
+ results = results.filter(
339
+ (s) => options.tags.some((tag) => s.tags?.includes(tag))
340
+ );
341
+ }
342
+ results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
343
+ const total = results.length;
344
+ const offset = options?.offset || 0;
345
+ const limit = options?.limit || 100;
346
+ return {
347
+ subscribers: results.slice(offset, offset + limit),
348
+ total
349
+ };
350
+ },
351
+ async deleteSubscriber(email) {
352
+ const normalizedEmail = email.toLowerCase();
353
+ const existed = subscribers.has(normalizedEmail);
354
+ subscribers.delete(normalizedEmail);
355
+ for (const [token, e] of tokenIndex.entries()) {
356
+ if (e === normalizedEmail) {
357
+ tokenIndex.delete(token);
358
+ break;
359
+ }
360
+ }
361
+ return existed;
362
+ },
363
+ async updateSubscriber(email, data) {
364
+ const normalizedEmail = email.toLowerCase();
365
+ const subscriber = subscribers.get(normalizedEmail);
366
+ if (!subscriber) return null;
367
+ const updated = {
368
+ ...subscriber,
369
+ ...data.source !== void 0 && { source: data.source },
370
+ ...data.tags !== void 0 && { tags: data.tags },
371
+ ...data.metadata !== void 0 && { metadata: data.metadata },
372
+ updatedAt: /* @__PURE__ */ new Date()
373
+ };
374
+ subscribers.set(normalizedEmail, updated);
375
+ return updated;
376
+ }
377
+ };
378
+ }
379
+ function createNoopAdapter() {
380
+ return {
381
+ async createSubscriber(input) {
382
+ return {
383
+ id: "noop",
384
+ email: input.email,
385
+ status: "pending",
386
+ source: input.source,
387
+ tags: input.tags,
388
+ metadata: input.metadata,
389
+ consentIp: input.ip,
390
+ consentAt: /* @__PURE__ */ new Date(),
391
+ createdAt: /* @__PURE__ */ new Date(),
392
+ updatedAt: /* @__PURE__ */ new Date()
393
+ };
394
+ },
395
+ async getSubscriberByEmail() {
396
+ return null;
397
+ },
398
+ async getSubscriberByToken() {
399
+ return null;
400
+ },
401
+ async confirmSubscriber() {
402
+ return null;
403
+ },
404
+ async unsubscribe() {
405
+ return true;
406
+ }
407
+ };
408
+ }
409
+ export {
410
+ createMemoryAdapter,
411
+ createNoopAdapter,
412
+ createPrismaAdapter,
413
+ createSupabaseAdapter
414
+ };
415
+ //# sourceMappingURL=index.js.map