@vatvaghool/create-ipl-dashboard 0.1.14 → 0.1.15

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/README.md CHANGED
@@ -27,9 +27,13 @@ npx @vatvaghool/create-ipl-dashboard [project-name] [options]
27
27
  | Prompt | Description |
28
28
  |--------|-------------|
29
29
  | Project name | Directory to scaffold into |
30
- | MongoDB URI | Your MongoDB connection string (press Enter for a default) |
30
+ | Storage backend | `mongodb` (default) or `google_sheets` |
31
+ | MongoDB URI | Your MongoDB connection string (only if MongoDB chosen, press Enter for default) |
32
+ | Google Sheet ID | Google Sheet ID (only if google_sheets chosen) |
33
+ | Service account email | Google service account email (only if google_sheets chosen) |
34
+ | Private key | Google service account private key (only if google_sheets chosen) |
31
35
  | League URL | The fantasy.iplt20.com league page URL |
32
- | Collection name | MongoDB collection to store this league's data (e.g. `ipl_2025_office_league`) |
36
+ | Collection/sheet name | MongoDB collection or Google sheet for this league's data |
33
37
  | League name | Display name for your league |
34
38
  | Teams | Team names (and optional owners) in your league
35
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vatvaghool/create-ipl-dashboard",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Scaffold an IPL fantasy cricket dashboard project",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { join } from "node:path";
4
4
  import { existsSync } from "node:fs";
5
5
  import { mkdir } from "node:fs/promises";
6
- import { getProjectName, getMongoUri, getLeagueUrl, getCollectionName, getLeagueName, getTeams, close } from "./prompts.mjs";
6
+ import { getProjectName, getMongoUri, getStorageBackend, getGoogleSheetsConfig, getLeagueUrl, getCollectionName, getLeagueName, getTeams, close } from "./prompts.mjs";
7
7
  import { scaffoldProject } from "./scaffold.mjs";
8
8
  import { scrapeTeamsFromUrl } from "./scraper.mjs";
9
9
 
@@ -61,7 +61,16 @@ async function main() {
61
61
 
62
62
  await mkdir(projectPath, { recursive: true });
63
63
 
64
- const mongoUri = await getMongoUri(DEFAULT_MONGODB_URI);
64
+ const storageBackend = await getStorageBackend();
65
+ let mongoUri = "";
66
+ let googleSheets = {};
67
+
68
+ if (storageBackend === "google_sheets") {
69
+ googleSheets = await getGoogleSheetsConfig();
70
+ } else {
71
+ mongoUri = await getMongoUri(DEFAULT_MONGODB_URI);
72
+ }
73
+
65
74
  const leagueUrl = await getLeagueUrl();
66
75
  const collectionName = await getCollectionName();
67
76
  const leagueName = await getLeagueName();
@@ -82,7 +91,7 @@ async function main() {
82
91
 
83
92
  close();
84
93
 
85
- await scaffoldProject(projectPath, { mongoUri, leagueUrl, collectionName, leagueName, teams, skipInstall: flags.skipInstall });
94
+ await scaffoldProject(projectPath, { mongoUri, storageBackend, googleSheets, leagueUrl, collectionName, leagueName, teams, skipInstall: flags.skipInstall });
86
95
 
87
96
  console.log("");
88
97
  console.log(" Next steps:");
package/src/prompts.mjs CHANGED
@@ -50,12 +50,36 @@ export async function getLeagueUrl() {
50
50
  return (await prompt("IPL fantasy league URL (or press Enter to skip, can be set later): ")).trim();
51
51
  }
52
52
 
53
+ export async function getStorageBackend() {
54
+ if (!isTTY) {
55
+ await consumeAllLines();
56
+ return (pipedLines[promptIndex++] || "mongodb").trim();
57
+ }
58
+ const answer = (await prompt("Storage backend (mongodb / google_sheets, default: mongodb): ")).trim().toLowerCase();
59
+ return answer === "google_sheets" ? "google_sheets" : "mongodb";
60
+ }
61
+
62
+ export async function getGoogleSheetsConfig() {
63
+ if (!isTTY) {
64
+ await consumeAllLines();
65
+ const sheetId = (pipedLines[promptIndex++] || "").trim();
66
+ const email = (pipedLines[promptIndex++] || "").trim();
67
+ const key = (pipedLines[promptIndex++] || "").trim();
68
+ return { sheetId, serviceAccountEmail: email, privateKey: key };
69
+ }
70
+ console.log("\n Google Sheets configuration:\n");
71
+ const sheetId = (await prompt(" Google Sheet ID: ")).trim();
72
+ const serviceAccountEmail = (await prompt(" Service account email: ")).trim();
73
+ const privateKey = (await prompt(" Private key (paste the full key, including -----BEGIN/END-----): ")).trim();
74
+ return { sheetId, serviceAccountEmail, privateKey };
75
+ }
76
+
53
77
  export async function getCollectionName() {
54
78
  if (!isTTY) {
55
79
  await consumeAllLines();
56
80
  return (pipedLines[promptIndex++] || "league_data").trim();
57
81
  }
58
- return (await prompt("MongoDB collection name for league data (e.g. ipl_2025_office_league): ")).trim() || "league_data";
82
+ return (await prompt("Collection/sheet name for this league's data (e.g. ipl_2025_office_league): ")).trim() || "league_data";
59
83
  }
60
84
 
61
85
  export async function getLeagueName() {
package/src/scaffold.mjs CHANGED
@@ -10,7 +10,7 @@ const TEMPLATE_DIR = join(PACKAGE_ROOT, "template");
10
10
 
11
11
  export async function scaffoldProject(
12
12
  projectPath,
13
- { mongoUri, leagueUrl, collectionName, leagueName, teams, skipInstall = false },
13
+ { mongoUri, storageBackend, googleSheets, leagueUrl, collectionName, leagueName, teams, skipInstall = false },
14
14
  ) {
15
15
  console.log(`\nCreating project at ${projectPath}...`);
16
16
 
@@ -35,6 +35,8 @@ export async function scaffoldProject(
35
35
 
36
36
  await writeEnvFile(projectPath, {
37
37
  mongoUri,
38
+ storageBackend,
39
+ googleSheets,
38
40
  leagueUrl,
39
41
  collectionName,
40
42
  leagueName,
@@ -42,7 +44,7 @@ export async function scaffoldProject(
42
44
  await writeTeamData(projectPath, teams);
43
45
  await writeLeagueData(projectPath, { leagueName, leagueUrl, teams });
44
46
  await writeMatchPointsPlaceholder(projectPath);
45
- await updatePackageJson(projectPath);
47
+ await updatePackageJson(projectPath, storageBackend);
46
48
 
47
49
  for (const f of [
48
50
  "app/api/ipl/live-snapshot.json",
@@ -57,15 +59,15 @@ export async function scaffoldProject(
57
59
  console.log(" Running npm install...");
58
60
  execSync("npm install", { cwd: projectPath, stdio: "inherit" });
59
61
 
60
- if (mongoUri && leagueName) {
61
- console.log(" Seeding league metadata into MongoDB...");
62
+ if (leagueName) {
63
+ console.log(" Seeding league metadata...");
62
64
  try {
63
65
  execSync(`npm run seed:league -- "${leagueName}" "${collectionName}"`, {
64
66
  cwd: projectPath,
65
67
  stdio: "inherit",
66
68
  });
67
69
  } catch {
68
- console.log(" League seed skipped (MongoDB may not be reachable yet)");
70
+ console.log(" League seed skipped (storage may not be reachable yet)");
69
71
  }
70
72
  }
71
73
  } else {
@@ -79,12 +81,16 @@ export async function scaffoldProject(
79
81
 
80
82
  async function writeEnvFile(
81
83
  projectPath,
82
- { mongoUri, leagueUrl, collectionName, leagueName },
84
+ { mongoUri, storageBackend, googleSheets, leagueUrl, collectionName, leagueName },
83
85
  ) {
84
86
  const envPath = join(projectPath, ".env");
85
87
  const lines = [
88
+ `STORAGE_BACKEND=${storageBackend || "mongodb"}`,
86
89
  `MONGODB_URI=${mongoUri || ""}`,
87
90
  `COLLECTION_NAME=${collectionName || "league_data"}`,
91
+ `GOOGLE_SHEET_ID=${(googleSheets && googleSheets.sheetId) || ""}`,
92
+ `GOOGLE_SERVICE_ACCOUNT_EMAIL=${(googleSheets && googleSheets.serviceAccountEmail) || ""}`,
93
+ `GOOGLE_PRIVATE_KEY=${(googleSheets && googleSheets.privateKey) || ""}`,
88
94
  `IPL_LEAGUE_URL=${leagueUrl || ""}`,
89
95
  `IPL_LEAGUE_NAME=${leagueName || ""}`,
90
96
  `IPL_POST_SECRET=`,
@@ -180,7 +186,7 @@ async function writeMatchPointsPlaceholder(projectPath) {
180
186
  await writeFile(join(dir, "match-points.ts"), content);
181
187
  }
182
188
 
183
- async function updatePackageJson(projectPath) {
189
+ async function updatePackageJson(projectPath, storageBackend) {
184
190
  const pkgPath = join(projectPath, "package.json");
185
191
  try {
186
192
  const raw = await readFile(pkgPath, "utf8");
@@ -189,6 +195,10 @@ async function updatePackageJson(projectPath) {
189
195
  pkg.private = false;
190
196
  delete pkg.scripts?.["generate:template"];
191
197
  pkg.scripts["seed:league"] = "node scripts/seed-league.mjs";
198
+ if (storageBackend === "google_sheets" && !pkg.dependencies.googleapis) {
199
+ pkg.dependencies = pkg.dependencies || {};
200
+ pkg.dependencies.googleapis = "^140.0.0";
201
+ }
192
202
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
193
203
  } catch {}
194
204
  }
@@ -12,7 +12,7 @@ import {
12
12
  getTransformOptions,
13
13
  } from "./transform";
14
14
  import { rawApiUsers } from "./data";
15
- import getMongoDb from "../../lib/useDb";
15
+ import { getStorage } from "../../lib/storage";
16
16
  import { config } from "../../lib/config.ts";
17
17
 
18
18
  export const dynamic = "force-dynamic";
@@ -54,39 +54,24 @@ const log = (message: string, details?: unknown) => {
54
54
 
55
55
  const opts = getTransformOptions();
56
56
 
57
- const getDb = async () => {
58
- try {
59
- return await getMongoDb();
60
- } catch (error) {
61
- console.error("MongoDB connection failed:", error);
62
- return null;
63
- }
64
- };
57
+ const storage = getStorage();
65
58
 
66
59
  const readMongoSnapshot = async () => {
67
- const db = await getDb();
68
- if (!db) return null;
69
60
  try {
70
- const document = await db
71
- .collection(DASHBOARD_COLLECTION)
72
- .findOne({ type: DASHBOARD_DOCUMENT_TYPE });
61
+ const document = await storage.readDocument(DASHBOARD_DOCUMENT_TYPE);
73
62
  return normalizePayload(document);
74
63
  } catch (error) {
75
- console.error("Failed to read IPL snapshot from MongoDB:", error);
64
+ console.error("Failed to read IPL snapshot from storage:", error);
76
65
  return null;
77
66
  }
78
67
  };
79
68
 
80
69
  const readMongoRawUsers = async () => {
81
- const db = await getDb();
82
- if (!db) return null;
83
70
  try {
84
- const document = await db
85
- .collection(DASHBOARD_COLLECTION)
86
- .findOne({ type: RAW_USERS_DOCUMENT_TYPE });
87
- return normalizeRawApiUsers(document?.users);
71
+ const document = await storage.readDocument(RAW_USERS_DOCUMENT_TYPE);
72
+ return normalizeRawApiUsers((document?.users as any[]) ?? null);
88
73
  } catch (error) {
89
- console.error("Failed to read IPL raw users from MongoDB:", error);
74
+ console.error("Failed to read IPL raw users from storage:", error);
90
75
  return null;
91
76
  }
92
77
  };
@@ -192,60 +177,44 @@ const snapshotSummary = (payload: ScrapedDashboardPayload | null) => {
192
177
  };
193
178
 
194
179
  const writeMongoSnapshot = async (payload: ScrapedDashboardPayload) => {
195
- const db = await getDb();
196
- if (!db) return { configured: false, stored: false };
180
+ if (!storage.isConfigured()) return { configured: false, stored: false };
197
181
  try {
198
182
  const current = await readMongoSnapshot();
199
183
  if (snapshotsAreEqual(current, payload)) {
200
184
  return { configured: true, stored: true, status: "unchanged" as const };
201
185
  }
202
- await db.collection(DASHBOARD_COLLECTION).updateOne(
203
- { type: DASHBOARD_DOCUMENT_TYPE },
204
- {
205
- $set: {
206
- type: DASHBOARD_DOCUMENT_TYPE,
207
- updatedAt: payload.updatedAt,
208
- dailyTransferUpdatedAt: payload.dailyTransferUpdatedAt,
209
- completedMatches: payload.completedMatches,
210
- leaders: payload.leaders.map((l) => ({
211
- ...l,
212
- teamId: resolveTeamId(l.name),
213
- })),
214
- },
215
- },
216
- { upsert: true },
217
- );
218
- return { configured: true, stored: true, status: "updated" as const };
186
+ const result = await storage.upsertDocument(DASHBOARD_DOCUMENT_TYPE, {
187
+ updatedAt: payload.updatedAt,
188
+ dailyTransferUpdatedAt: payload.dailyTransferUpdatedAt,
189
+ completedMatches: payload.completedMatches,
190
+ leaders: payload.leaders.map((l) => ({
191
+ ...l,
192
+ teamId: resolveTeamId(l.name),
193
+ })),
194
+ });
195
+ return { configured: result.configured, stored: result.stored, status: result.status || "updated" };
219
196
  } catch (error) {
220
- console.error("Failed to write IPL snapshot to MongoDB:", error);
197
+ console.error("Failed to write IPL snapshot to storage:", error);
221
198
  return { configured: true, stored: false, status: "failed" as const };
222
199
  }
223
200
  };
224
201
 
225
202
  const writeMongoRawUsers = async (users: RawApiUser[]) => {
226
- const db = await getDb();
227
- if (!db) return { configured: false, stored: false };
203
+ if (!storage.isConfigured()) return { configured: false, stored: false };
228
204
  try {
229
- await db.collection(DASHBOARD_COLLECTION).updateOne(
230
- { type: RAW_USERS_DOCUMENT_TYPE },
231
- {
232
- $set: {
233
- type: RAW_USERS_DOCUMENT_TYPE,
234
- updatedAt: new Date().toISOString(),
235
- users: users.map((user) => ({
236
- rno: user.rno,
237
- temname: user.temname,
238
- points: user.points,
239
- teamId: resolveTeamId(user.temname),
240
- matches: [...user.matches].sort((a, b) => a.matchId - b.matchId),
241
- })),
242
- },
243
- },
244
- { upsert: true },
245
- );
246
- return { configured: true, stored: true };
205
+ const result = await storage.upsertDocument(RAW_USERS_DOCUMENT_TYPE, {
206
+ updatedAt: new Date().toISOString(),
207
+ users: users.map((user) => ({
208
+ rno: user.rno,
209
+ temname: user.temname,
210
+ points: user.points,
211
+ teamId: resolveTeamId(user.temname),
212
+ matches: [...user.matches].sort((a, b) => a.matchId - b.matchId),
213
+ })),
214
+ });
215
+ return { configured: result.configured, stored: result.stored };
247
216
  } catch (error) {
248
- console.error("Failed to write IPL raw users to MongoDB:", error);
217
+ console.error("Failed to write IPL raw users to storage:", error);
249
218
  return { configured: true, stored: false };
250
219
  }
251
220
  };
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
- import getMongoDb from "../../../lib/useDb";
3
+ import { getStorage } from "../../../lib/storage";
4
4
  import { config } from "../../../lib/config.ts";
5
5
  import type {
6
6
  ScrapedTransferItem,
@@ -49,35 +49,17 @@ const log = (message: string, details?: unknown) => {
49
49
  console.log(LOG_TAG, message, details);
50
50
  };
51
51
 
52
- const getDb = async () => {
53
- try {
54
- return await getMongoDb();
55
- } catch (error) {
56
- console.error("MongoDB connection failed:", error);
57
- return null;
58
- }
59
- };
52
+ const storage = getStorage();
60
53
 
61
54
  const readMongoTransfers = async () => {
62
- const db = await getDb();
63
-
64
- if (!db) {
65
- return null;
66
- }
67
-
68
55
  try {
69
- const documents = await db
70
- .collection(DASHBOARD_COLLECTION)
71
- .find({ type: TRANSFER_DOCUMENT_TYPE })
72
- .toArray();
73
-
56
+ const documents = await storage.readDocuments(TRANSFER_DOCUMENT_TYPE);
74
57
  const teams = documents
75
58
  .map(normalizeTransferItem)
76
59
  .filter(Boolean) as ScrapedTransferItem[];
77
-
78
60
  return teams.length > 0 ? teams : null;
79
61
  } catch (error) {
80
- console.error("Failed to read IPL transfer stats from MongoDB:", error);
62
+ console.error("Failed to read IPL transfer stats from storage:", error);
81
63
  return null;
82
64
  }
83
65
  };
@@ -98,45 +80,23 @@ const readSnapshotFile = async () => {
98
80
  };
99
81
 
100
82
  const writeMongoTransfer = async (record: ScrapedTransferItem) => {
101
- const db = await getDb();
102
-
103
- if (!db) {
104
- return {
105
- configured: false,
106
- stored: false,
107
- };
108
- }
109
-
83
+ if (!storage.isConfigured()) return { configured: false, stored: false };
110
84
  try {
111
- await db.collection(DASHBOARD_COLLECTION).updateOne(
85
+ const result = await storage.upsertDocument(
86
+ TRANSFER_DOCUMENT_TYPE,
112
87
  {
113
- type: TRANSFER_DOCUMENT_TYPE,
114
88
  team: record.team,
89
+ matchesPlayed: record.matchesPlayed,
90
+ boostersUsed: record.boostersUsed,
91
+ transfersLeft: record.transfersLeft,
92
+ updatedAt: record.updatedAt,
115
93
  },
116
- {
117
- $set: {
118
- type: TRANSFER_DOCUMENT_TYPE,
119
- team: record.team,
120
- matchesPlayed: record.matchesPlayed,
121
- boostersUsed: record.boostersUsed,
122
- transfersLeft: record.transfersLeft,
123
- updatedAt: record.updatedAt,
124
- },
125
- },
126
- { upsert: true },
94
+ { team: record.team },
127
95
  );
128
-
129
- return {
130
- configured: true,
131
- stored: true,
132
- };
96
+ return { configured: result.configured, stored: result.stored };
133
97
  } catch (error) {
134
- console.error("Failed to write IPL transfer stats to MongoDB:", error);
135
-
136
- return {
137
- configured: true,
138
- stored: false,
139
- };
98
+ console.error("Failed to write IPL transfer stats to storage:", error);
99
+ return { configured: true, stored: false };
140
100
  }
141
101
  };
142
102
 
@@ -1,8 +1,8 @@
1
1
  import { NextResponse } from "next/server";
2
- import getMongoDb from "../../../lib/useDb";
2
+ import { getStorage } from "../../../lib/storage";
3
3
  import { config } from "../../../lib/config.ts";
4
4
  import { normalizePayload } from "../../ipl/transform";
5
- import { summarizeTransferSnapshot } from "../../ipl/transfers/transform";
5
+ import { normalizeTransferItem, summarizeTransferSnapshot } from "../../ipl/transfers/transform";
6
6
 
7
7
  export const dynamic = "force-dynamic";
8
8
  export const revalidate = 0;
@@ -64,73 +64,38 @@ const getFreshness = (
64
64
  };
65
65
  };
66
66
 
67
- const getDb = async () => {
68
- try {
69
- return await getMongoDb();
70
- } catch (error) {
71
- console.error("MongoDB connection failed:", error);
72
- return null;
73
- }
74
- };
67
+ const storage = getStorage();
75
68
 
76
69
  export async function GET() {
77
- const mongoConfigured = Boolean(process.env.MONGODB_URI);
78
- const db = await getDb();
70
+ const storageConfigured = storage.isConfigured();
71
+ const storageConnected = await storage.isConnected();
79
72
 
80
- if (!db) {
73
+ if (!storageConnected) {
81
74
  return NextResponse.json(
82
75
  {
83
76
  ok: !isProduction,
84
77
  mode: isProduction ? "cloud" : "local",
85
- mongoConfigured,
78
+ mongoConfigured: storageConfigured,
86
79
  mongoConnected: false,
87
- reason: mongoConfigured
88
- ? "MongoDB connection failed."
89
- : "MONGODB_URI is not configured.",
80
+ storageBackend: process.env.STORAGE_BACKEND || "mongodb",
81
+ reason: storageConfigured
82
+ ? "Storage connection failed."
83
+ : "Storage is not configured.",
90
84
  },
91
- { status: mongoConfigured ? 503 : 200 },
85
+ { status: storageConfigured ? 503 : 200 },
92
86
  );
93
87
  }
94
88
 
95
89
  const [dashboardDocument, rawUsersDocument, transferDocuments] = await Promise.all([
96
- db.collection(DASHBOARD_COLLECTION).findOne({ type: DASHBOARD_DOCUMENT_TYPE }),
97
- db.collection(DASHBOARD_COLLECTION).findOne({ type: RAW_USERS_DOCUMENT_TYPE }),
98
- db
99
- .collection(DASHBOARD_COLLECTION)
100
- .find({ type: TRANSFER_DOCUMENT_TYPE })
101
- .toArray(),
90
+ storage.readDocument(DASHBOARD_DOCUMENT_TYPE),
91
+ storage.readDocument(RAW_USERS_DOCUMENT_TYPE),
92
+ storage.readDocuments(TRANSFER_DOCUMENT_TYPE),
102
93
  ]);
103
94
  const snapshot = normalizePayload(dashboardDocument);
104
- const transfers = summarizeTransferSnapshot(
105
- transferDocuments
106
- .map((document) => ({
107
- team:
108
- typeof document.team === "string" ? document.team : "",
109
- matchesPlayed:
110
- typeof document.matchesPlayed === "number"
111
- ? document.matchesPlayed
112
- : Number.NaN,
113
- boostersUsed:
114
- typeof document.boostersUsed === "number"
115
- ? document.boostersUsed
116
- : Number.NaN,
117
- transfersLeft:
118
- typeof document.transfersLeft === "string"
119
- ? document.transfersLeft
120
- : String(document.transfersLeft ?? "").trim(),
121
- updatedAt:
122
- typeof document.updatedAt === "string"
123
- ? document.updatedAt
124
- : new Date(0).toISOString(),
125
- }))
126
- .filter(
127
- (item) =>
128
- item.team &&
129
- Number.isFinite(item.matchesPlayed) &&
130
- Number.isFinite(item.boostersUsed) &&
131
- item.transfersLeft,
132
- ),
133
- );
95
+ const transferItems = transferDocuments
96
+ .map((doc) => normalizeTransferItem(doc as any))
97
+ .filter(Boolean) as any[];
98
+ const transfers = summarizeTransferSnapshot(transferItems);
134
99
  const transfersUpdatedAt =
135
100
  transfers.teams.length > 0 ? transfers.updatedAt : undefined;
136
101
  const dashboardFreshness = getFreshness(
@@ -190,8 +155,9 @@ export async function GET() {
190
155
  status,
191
156
  mode: isProduction ? "cloud" : "local",
192
157
  checkedAt: new Date().toISOString(),
193
- mongoConfigured,
194
- mongoConnected: true,
158
+ mongoConfigured: storageConfigured,
159
+ mongoConnected: storageConnected,
160
+ storageBackend: process.env.STORAGE_BACKEND || "mongodb",
195
161
  localSnapshotFallbackEnabled: !isProduction,
196
162
  thresholds: {
197
163
  dashboardStaleMinutes,
@@ -207,7 +173,7 @@ export async function GET() {
207
173
  }
208
174
  : null,
209
175
  rawUsers: {
210
- count: Array.isArray(rawUsersDocument?.users) ? rawUsersDocument.users.length : 0,
176
+ count: Array.isArray((rawUsersDocument as any)?.users) ? (rawUsersDocument as any).users.length : 0,
211
177
  updatedAt:
212
178
  typeof rawUsersDocument?.updatedAt === "string"
213
179
  ? rawUsersDocument.updatedAt
@@ -1,6 +1,10 @@
1
1
  import path from "node:path";
2
2
 
3
3
  export const config = {
4
+ storage: {
5
+ backend: process.env.STORAGE_BACKEND || "mongodb",
6
+ },
7
+
4
8
  mongodb: {
5
9
  databaseName: process.env.IPL_DB_NAME || "ipl",
6
10
  collectionName: process.env.IPL_COLLECTION_NAME || "ipl",
@@ -0,0 +1,147 @@
1
+ import type { IStorage, StorageResult } from "./types";
2
+
3
+ const SHEET_ID = () => process.env.GOOGLE_SHEET_ID || "";
4
+ const SHEET_NAMES: Record<string, string> = {
5
+ dashboard: "dashboard",
6
+ "raw-users": "raw_users",
7
+ "transfer-stats": "transfers",
8
+ league: "league",
9
+ };
10
+
11
+ function sheetNameFor(type: string): string {
12
+ return SHEET_NAMES[type] || type.replace(/[^a-z0-9_]/g, "_");
13
+ }
14
+
15
+ let sheetsClient: any = null;
16
+
17
+ async function getSheetsClient() {
18
+ if (sheetsClient) return sheetsClient;
19
+ try {
20
+ const { google } = await import("googleapis");
21
+ const auth = new google.auth.GoogleAuth({
22
+ credentials: {
23
+ client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
24
+ private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
25
+ },
26
+ scopes: ["https://www.googleapis.com/auth/spreadsheets"],
27
+ });
28
+ sheetsClient = google.sheets({ version: "v4", auth });
29
+ return sheetsClient;
30
+ } catch (error) {
31
+ console.error("Failed to create Google Sheets client:", error);
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export class GoogleSheetsStorage implements IStorage {
37
+ isConfigured(): boolean {
38
+ return Boolean(process.env.GOOGLE_SHEET_ID && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL && process.env.GOOGLE_PRIVATE_KEY);
39
+ }
40
+
41
+ async isConnected(): Promise<boolean> {
42
+ const client = await getSheetsClient();
43
+ return client !== null;
44
+ }
45
+
46
+ private async ensureSheet(sheetName: string): Promise<void> {
47
+ const client = await getSheetsClient();
48
+ if (!client) return;
49
+ try {
50
+ const res = await client.spreadsheets.get({ spreadsheetId: SHEET_ID() });
51
+ const sheets: any[] = res.data.sheets || [];
52
+ if (sheets.some((s: any) => s.properties?.title === sheetName)) return;
53
+ await client.spreadsheets.batchUpdate({
54
+ spreadsheetId: SHEET_ID(),
55
+ requestBody: {
56
+ requests: [{ addSheet: { properties: { title: sheetName } } }],
57
+ },
58
+ });
59
+ } catch {
60
+ // Sheet already exists or can't be created
61
+ }
62
+ }
63
+
64
+ async readDocument(type: string): Promise<Record<string, unknown> | null> {
65
+ const client = await getSheetsClient();
66
+ if (!client) return null;
67
+ const sheetName = sheetNameFor(type);
68
+ try {
69
+ const res = await client.spreadsheets.values.get({
70
+ spreadsheetId: SHEET_ID(),
71
+ range: `${sheetName}!A:C`,
72
+ });
73
+ const rows: string[][] = res.data.values || [];
74
+ const dataRow = rows.find((r) => r[0] === type);
75
+ if (dataRow?.[1]) {
76
+ return { type, ...JSON.parse(dataRow[1]), updatedAt: dataRow[2] || new Date().toISOString() };
77
+ }
78
+ return null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ async upsertDocument(
85
+ type: string,
86
+ data: Record<string, unknown>,
87
+ _filter?: Record<string, unknown>,
88
+ ): Promise<StorageResult> {
89
+ const client = await getSheetsClient();
90
+ if (!client) return { configured: true, stored: false, status: "no_connection" };
91
+ const sheetName = sheetNameFor(type);
92
+ await this.ensureSheet(sheetName);
93
+ try {
94
+ const res = await client.spreadsheets.values.get({
95
+ spreadsheetId: SHEET_ID(),
96
+ range: `${sheetName}!A:C`,
97
+ });
98
+ const rows: string[][] = res.data.values || [];
99
+ const rowIndex = rows.findIndex((r) => r[0] === type);
100
+ const jsonData = JSON.stringify(data);
101
+ const updatedAt = new Date().toISOString();
102
+ if (rowIndex >= 0) {
103
+ await client.spreadsheets.values.update({
104
+ spreadsheetId: SHEET_ID(),
105
+ range: `${sheetName}!A${rowIndex + 1}:C${rowIndex + 1}`,
106
+ valueInputOption: "RAW",
107
+ requestBody: { values: [[type, jsonData, updatedAt]] },
108
+ });
109
+ } else {
110
+ await client.spreadsheets.values.append({
111
+ spreadsheetId: SHEET_ID(),
112
+ range: `${sheetName}!A:C`,
113
+ valueInputOption: "RAW",
114
+ requestBody: { values: [[type, jsonData, updatedAt]] },
115
+ });
116
+ }
117
+ return { configured: true, stored: true, status: "updated" };
118
+ } catch (error) {
119
+ console.error(`Failed to write document type "${type}" to Google Sheets:`, error);
120
+ return { configured: true, stored: false, status: "failed" };
121
+ }
122
+ }
123
+
124
+ async readDocuments(type: string): Promise<Record<string, unknown>[]> {
125
+ const client = await getSheetsClient();
126
+ if (!client) return [];
127
+ const sheetName = sheetNameFor(type);
128
+ try {
129
+ const res = await client.spreadsheets.values.get({
130
+ spreadsheetId: SHEET_ID(),
131
+ range: `${sheetName}!A:C`,
132
+ });
133
+ const rows: string[][] = res.data.values || [];
134
+ return rows
135
+ .filter((r) => r[0] && r[0] !== type)
136
+ .map((r) => {
137
+ try {
138
+ return { team: r[0], ...JSON.parse(r[1] || "{}"), updatedAt: r[2] || new Date().toISOString() };
139
+ } catch {
140
+ return { team: r[0], updatedAt: r[2] || new Date().toISOString() };
141
+ }
142
+ });
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,20 @@
1
+ import type { IStorage } from "./types";
2
+ import { MongoStorage } from "./mongo-storage";
3
+ import { GoogleSheetsStorage } from "./google-sheets-storage";
4
+
5
+ let _instance: IStorage | null = null;
6
+
7
+ function detectBackend(): "mongodb" | "google_sheets" {
8
+ if (process.env.STORAGE_BACKEND === "google_sheets") return "google_sheets";
9
+ if (process.env.GOOGLE_SHEET_ID && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL && process.env.GOOGLE_PRIVATE_KEY) return "google_sheets";
10
+ return "mongodb";
11
+ }
12
+
13
+ export function getStorage(): IStorage {
14
+ if (_instance) return _instance;
15
+ const backend = detectBackend();
16
+ _instance = backend === "google_sheets" ? new GoogleSheetsStorage() : new MongoStorage();
17
+ return _instance;
18
+ }
19
+
20
+ export type { IStorage } from "./types";
@@ -0,0 +1,53 @@
1
+ import getMongoDb from "../useDb";
2
+ import { config } from "../config";
3
+ import type { IStorage, StorageResult } from "./types";
4
+
5
+ const COLLECTION = config.mongodb.collectionName;
6
+
7
+ export class MongoStorage implements IStorage {
8
+ isConfigured(): boolean {
9
+ return Boolean(process.env.MONGODB_URI);
10
+ }
11
+
12
+ async isConnected(): Promise<boolean> {
13
+ const db = await getMongoDb();
14
+ return db !== null;
15
+ }
16
+
17
+ async readDocument(type: string): Promise<Record<string, unknown> | null> {
18
+ const db = await getMongoDb();
19
+ if (!db) return null;
20
+ try {
21
+ return (await db.collection(COLLECTION).findOne({ type })) as Record<string, unknown> | null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ async upsertDocument(
28
+ type: string,
29
+ data: Record<string, unknown>,
30
+ filter?: Record<string, unknown>,
31
+ ): Promise<StorageResult> {
32
+ const db = await getMongoDb();
33
+ if (!db) return { configured: true, stored: false, status: "no_connection" };
34
+ try {
35
+ const query = filter ? { type, ...filter } : { type };
36
+ await db.collection(COLLECTION).updateOne(query, { $set: { type, ...data } }, { upsert: true });
37
+ return { configured: true, stored: true, status: "updated" };
38
+ } catch (error) {
39
+ console.error(`Failed to upsert document type "${type}" in MongoDB:`, error);
40
+ return { configured: true, stored: false, status: "failed" };
41
+ }
42
+ }
43
+
44
+ async readDocuments(type: string): Promise<Record<string, unknown>[]> {
45
+ const db = await getMongoDb();
46
+ if (!db) return [];
47
+ try {
48
+ return (await db.collection(COLLECTION).find({ type }).toArray()) as Record<string, unknown>[];
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,13 @@
1
+ export interface StorageResult {
2
+ configured: boolean;
3
+ stored: boolean;
4
+ status?: string;
5
+ }
6
+
7
+ export interface IStorage {
8
+ readDocument(type: string): Promise<Record<string, unknown> | null>;
9
+ upsertDocument(type: string, data: Record<string, unknown>, filter?: Record<string, unknown>): Promise<StorageResult>;
10
+ readDocuments(type: string): Promise<Record<string, unknown>[]>;
11
+ isConfigured(): boolean;
12
+ isConnected(): Promise<boolean>;
13
+ }
@@ -29,6 +29,61 @@ async function main() {
29
29
 
30
30
  const collectionName = process.argv[3] || process.env.COLLECTION_NAME?.trim() || sanitizeCollectionName(leagueName);
31
31
 
32
+ const isGoogleSheets = process.env.STORAGE_BACKEND === "google_sheets";
33
+
34
+ if (isGoogleSheets) {
35
+ const sheetId = process.env.GOOGLE_SHEET_ID;
36
+ const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
37
+ const key = process.env.GOOGLE_PRIVATE_KEY;
38
+
39
+ if (!sheetId || !email || !key) {
40
+ console.log("Google Sheets not configured. Skipping league seed.");
41
+ return;
42
+ }
43
+
44
+ const leagueUrl = process.env.IPL_LEAGUE_URL?.trim();
45
+
46
+ let teams = [];
47
+ const leagueDataPath = join(ROOT, "app/data/league.ts");
48
+ if (existsSync(leagueDataPath)) {
49
+ try {
50
+ const content = readFileSync(leagueDataPath, "utf8");
51
+ const match = content.match(/teams:\s*\[([\s\S]*?)\],/);
52
+ if (match) {
53
+ const entries = [...match[1].matchAll(/\{\s*id:\s*(\d+),\s*name:\s*"([^"]+)",\s*owner:\s*"([^"]+)"\s*\}/g)];
54
+ teams = entries.map(([, id, name, owner]) => ({
55
+ id: Number(id),
56
+ name,
57
+ owner,
58
+ }));
59
+ }
60
+ } catch {}
61
+ }
62
+
63
+ const { google } = await import("googleapis");
64
+ const auth = new google.auth.GoogleAuth({
65
+ credentials: { client_email: email, private_key: key.replace(/\\n/g, "\n") },
66
+ scopes: ["https://www.googleapis.com/auth/spreadsheets"],
67
+ });
68
+ const sheets = google.sheets({ version: "v4", auth });
69
+ const sheetName = collectionName.replace(/[^a-z0-9_]/g, "_");
70
+
71
+ try {
72
+ await sheets.spreadsheets.values.append({
73
+ spreadsheetId: sheetId,
74
+ range: `${sheetName}!A:C`,
75
+ valueInputOption: "RAW",
76
+ requestBody: {
77
+ values: [["league", JSON.stringify({ name: leagueName, leagueUrl, teams }), new Date().toISOString()]],
78
+ },
79
+ });
80
+ console.log(`League "${leagueName}" seeded in sheet "${sheetName}" with ${teams.length} team(s).`);
81
+ } catch (error) {
82
+ console.error("Failed to seed league in Google Sheets:", error.message);
83
+ }
84
+ return;
85
+ }
86
+
32
87
  const uri = process.env.MONGODB_URI;
33
88
  if (!uri) {
34
89
  console.log("MONGODB_URI not set. Skipping league seed.");