@vatvaghool/create-ipl-dashboard 0.1.14 → 0.1.17
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 +53 -14
- package/package.json +1 -1
- package/src/index.mjs +3 -11
- package/src/prompts.mjs +105 -40
- package/src/scaffold.mjs +21 -15
- package/template/app/api/ipl/route.ts +32 -63
- package/template/app/api/ipl/transfers/route.ts +15 -55
- package/template/app/api/ops/status/route.ts +23 -57
- package/template/app/lib/config.ts +4 -0
- package/template/app/lib/storage/google-sheets-storage.ts +147 -0
- package/template/app/lib/storage/index.ts +20 -0
- package/template/app/lib/storage/mongo-storage.ts +53 -0
- package/template/app/lib/storage/types.ts +13 -0
- package/template/scripts/seed-league.mjs +55 -0
package/README.md
CHANGED
|
@@ -27,13 +27,16 @@ npx @vatvaghool/create-ipl-dashboard [project-name] [options]
|
|
|
27
27
|
| Prompt | Description |
|
|
28
28
|
|--------|-------------|
|
|
29
29
|
| Project name | Directory to scaffold into |
|
|
30
|
-
|
|
|
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
|
|
33
|
-
| League name |
|
|
34
|
-
| Teams | Team names (and optional owners) in your league
|
|
36
|
+
| Collection/sheet name | MongoDB collection or Google sheet for this league's data |
|
|
37
|
+
| League name | Used as collection/table name in storage |
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
Set team names later via `app/data/teams.ts` or use `--scrape` to auto-detect from the league URL during scaffolding.
|
|
37
40
|
|
|
38
41
|
## Screenshots
|
|
39
42
|
|
|
@@ -81,16 +84,31 @@ cd my-league
|
|
|
81
84
|
|
|
82
85
|
# Start the dev server
|
|
83
86
|
npm run dev:simple
|
|
84
|
-
|
|
85
|
-
# Capture auth state for scrapers (one-time setup)
|
|
86
|
-
npm run capture:ipl-auth
|
|
87
|
-
|
|
88
|
-
# Scrape live leaderboard data
|
|
89
|
-
npm run sync:ipl
|
|
90
87
|
```
|
|
91
88
|
|
|
92
89
|
Open http://localhost:3000 to see your dashboard.
|
|
93
90
|
|
|
91
|
+
### Available commands
|
|
92
|
+
|
|
93
|
+
| Command | Description |
|
|
94
|
+
|---------|-------------|
|
|
95
|
+
| `npm run dev` | Dev server with welcome splash |
|
|
96
|
+
| `npm run dev:simple` | Dev server (simple, no splash) |
|
|
97
|
+
| `npm run build` | Production build |
|
|
98
|
+
| `npm start` | Production server |
|
|
99
|
+
| `npm run capture:ipl-auth` | Capture Playwright login state (one-time) |
|
|
100
|
+
| `npm run sync:ipl` | Scrape live leaderboard snapshot |
|
|
101
|
+
| `npm run sync:ipl:watch` | Scrape leaderboard in watch mode (polls every 2 min) |
|
|
102
|
+
| `npm run sync:ipl:transfers-daily` | Scrape transfer/booster data |
|
|
103
|
+
| `npm run sync:cloud` | Run both leaderboard + transfer sync (for cloud jobs) |
|
|
104
|
+
| `npm run seed:league` | Seed league metadata into storage |
|
|
105
|
+
| `npm run seed:mongodb` | Seed initial raw user data from `data.ts` |
|
|
106
|
+
| `npm run seed:mongodb:reset` | Reset and re-seed MongoDB data |
|
|
107
|
+
| `npm run verify:production` | Verify production setup |
|
|
108
|
+
| `npm run monitor:ops` | Check ops health status |
|
|
109
|
+
| `npm run test` | Run test suite |
|
|
110
|
+
| `npm run lint` | Run linter |
|
|
111
|
+
|
|
94
112
|
## How it works
|
|
95
113
|
|
|
96
114
|
The CLI:
|
|
@@ -117,11 +135,13 @@ The template includes all dashboard components, API endpoints, scrapers, and tes
|
|
|
117
135
|
npx @vatvaghool/create-ipl-dashboard another-league
|
|
118
136
|
```
|
|
119
137
|
|
|
120
|
-
Provide a different
|
|
138
|
+
Provide a different league name and the new league will be stored in its own collection/sheet — data stays fully isolated.
|
|
121
139
|
|
|
122
140
|
### Viewing seeded data
|
|
123
141
|
|
|
124
|
-
|
|
142
|
+
**MongoDB:** Connect with any MongoDB client. Each league appears as a separate collection with `type: "league"`.
|
|
143
|
+
|
|
144
|
+
**Google Sheets:** Open your spreadsheet in a browser. Each league has its own sheet (tab) with league metadata in row 2.
|
|
125
145
|
|
|
126
146
|
### Production deployment
|
|
127
147
|
|
|
@@ -131,4 +151,23 @@ npm run build
|
|
|
131
151
|
npx vercel --prod
|
|
132
152
|
```
|
|
133
153
|
|
|
134
|
-
Set `IPL_POST_SECRET` in your Vercel dashboard.
|
|
154
|
+
Set `IPL_POST_SECRET` in your Vercel dashboard. Storage credentials are already populated in the scaffolded `.env`.
|
|
155
|
+
|
|
156
|
+
### Data sync workflow
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# 1. Capture browser login (one-time)
|
|
160
|
+
npm run capture:ipl-auth
|
|
161
|
+
|
|
162
|
+
# 2. Scrape leaderboard (manual or watch mode)
|
|
163
|
+
npm run sync:ipl
|
|
164
|
+
npm run sync:ipl:watch # auto-poll every 2 min
|
|
165
|
+
|
|
166
|
+
# 3. Scrape transfer/booster data
|
|
167
|
+
npm run sync:ipl:transfers-daily
|
|
168
|
+
|
|
169
|
+
# 4. Run all scrapers (for cloud jobs)
|
|
170
|
+
npm run sync:cloud
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
See the [Available commands](#quick-start-after-scaffold) table above for all options.
|
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -3,12 +3,10 @@
|
|
|
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,
|
|
6
|
+
import { getProjectName, getConnectionUrl, getLeagueUrl, getLeagueName, close } from "./prompts.mjs";
|
|
7
7
|
import { scaffoldProject } from "./scaffold.mjs";
|
|
8
8
|
import { scrapeTeamsFromUrl } from "./scraper.mjs";
|
|
9
9
|
|
|
10
|
-
const DEFAULT_MONGODB_URI = "mongodb+srv://admin:admin@cricket.ptjjrub.mongodb.net/?appName=cricket";
|
|
11
|
-
|
|
12
10
|
function printHelp(exitCode) {
|
|
13
11
|
console.log(`
|
|
14
12
|
create-ipl-dashboard [project-name] [options]
|
|
@@ -61,9 +59,8 @@ async function main() {
|
|
|
61
59
|
|
|
62
60
|
await mkdir(projectPath, { recursive: true });
|
|
63
61
|
|
|
64
|
-
const
|
|
62
|
+
const conn = await getConnectionUrl();
|
|
65
63
|
const leagueUrl = await getLeagueUrl();
|
|
66
|
-
const collectionName = await getCollectionName();
|
|
67
64
|
const leagueName = await getLeagueName();
|
|
68
65
|
let teams;
|
|
69
66
|
|
|
@@ -72,17 +69,12 @@ async function main() {
|
|
|
72
69
|
teams = await scrapeTeamsFromUrl(leagueUrl);
|
|
73
70
|
if (teams && teams.length > 0) {
|
|
74
71
|
console.log(` Found ${teams.length} team(s): ${teams.map((t) => t.name).join(", ")}`);
|
|
75
|
-
} else {
|
|
76
|
-
console.log(" Could not auto-detect teams. Enter them manually:");
|
|
77
|
-
teams = await getTeams();
|
|
78
72
|
}
|
|
79
|
-
} else {
|
|
80
|
-
teams = await getTeams();
|
|
81
73
|
}
|
|
82
74
|
|
|
83
75
|
close();
|
|
84
76
|
|
|
85
|
-
await scaffoldProject(projectPath, {
|
|
77
|
+
await scaffoldProject(projectPath, { conn, leagueUrl, leagueName, teams, skipInstall: flags.skipInstall });
|
|
86
78
|
|
|
87
79
|
console.log("");
|
|
88
80
|
console.log(" Next steps:");
|
package/src/prompts.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { stdin as input, stdout as output, stdin } from "node:process";
|
|
3
|
+
import * as readline from "node:readline";
|
|
3
4
|
|
|
4
5
|
const isTTY = stdin.isTTY;
|
|
5
6
|
|
|
@@ -24,6 +25,86 @@ function prompt(query) {
|
|
|
24
25
|
});
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
const BACKEND_VALUES = { mongodb: "mongodb", google_sheets: "google_sheets" };
|
|
29
|
+
|
|
30
|
+
async function select(message, choices, valueMap) {
|
|
31
|
+
if (!isTTY) {
|
|
32
|
+
await consumeAllLines();
|
|
33
|
+
const answer = (pipedLines[promptIndex++] || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
34
|
+
const match = choices.find((c) => c.toLowerCase().replace(/[\s_-]+/g, "").startsWith(answer));
|
|
35
|
+
const idx = match ? choices.indexOf(match) : 0;
|
|
36
|
+
return valueMap ? valueMap[Object.keys(valueMap)[idx]] : choices[idx];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
let selected = 0;
|
|
41
|
+
const rl = createInterface({ input, output });
|
|
42
|
+
|
|
43
|
+
function render(n) {
|
|
44
|
+
for (let i = 0; i < n; i++) {
|
|
45
|
+
readline.moveCursor(output, 0, -1);
|
|
46
|
+
readline.clearLine(output, 0);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
process.stdout.write(`${message}\n`);
|
|
51
|
+
choices.forEach((c, i) => {
|
|
52
|
+
process.stdout.write(i === selected ? `\x1B[7m ${c} \x1B[0m\n` : ` ${c}\n`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const numLines = choices.length;
|
|
56
|
+
|
|
57
|
+
readline.emitKeypressEvents(input);
|
|
58
|
+
if (input.isTTY) input.setRawMode(true);
|
|
59
|
+
|
|
60
|
+
function cleanup() {
|
|
61
|
+
input.setRawMode(false);
|
|
62
|
+
input.pause();
|
|
63
|
+
input.removeListener("keypress", handler);
|
|
64
|
+
rl.close();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handler(str, key) {
|
|
68
|
+
if (!key) return;
|
|
69
|
+
if (key.name === "up" && selected > 0) {
|
|
70
|
+
selected--;
|
|
71
|
+
for (let i = 0; i < numLines; i++) readline.moveCursor(output, 0, -1);
|
|
72
|
+
choices.forEach((c, i) => {
|
|
73
|
+
readline.clearLine(output, 0);
|
|
74
|
+
readline.cursorTo(output, 0);
|
|
75
|
+
output.write(i === selected ? `\x1B[7m ${c} \x1B[0m` : ` ${c} `);
|
|
76
|
+
if (i < numLines - 1) output.write("\n");
|
|
77
|
+
});
|
|
78
|
+
output.write("\n");
|
|
79
|
+
} else if (key.name === "down" && selected < numLines - 1) {
|
|
80
|
+
selected++;
|
|
81
|
+
for (let i = 0; i < numLines; i++) readline.moveCursor(output, 0, -1);
|
|
82
|
+
choices.forEach((c, i) => {
|
|
83
|
+
readline.clearLine(output, 0);
|
|
84
|
+
readline.cursorTo(output, 0);
|
|
85
|
+
output.write(i === selected ? `\x1B[7m ${c} \x1B[0m` : ` ${c} `);
|
|
86
|
+
if (i < numLines - 1) output.write("\n");
|
|
87
|
+
});
|
|
88
|
+
output.write("\n");
|
|
89
|
+
} else if (key.name === "return") {
|
|
90
|
+
cleanup();
|
|
91
|
+
for (let i = 0; i < numLines + 1; i++) {
|
|
92
|
+
readline.moveCursor(output, 0, -1);
|
|
93
|
+
readline.clearLine(output, 0);
|
|
94
|
+
}
|
|
95
|
+
output.write(`${message} \x1B[32m${choices[selected]}\x1B[0m\n`);
|
|
96
|
+
const keys = Object.keys(valueMap || {});
|
|
97
|
+
resolve(valueMap ? valueMap[keys[selected]] : choices[selected]);
|
|
98
|
+
} else if (key.name === "c" && key.ctrl) {
|
|
99
|
+
cleanup();
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
input.on("keypress", handler);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
27
108
|
export async function getProjectName(args) {
|
|
28
109
|
if (args[0]) return args[0];
|
|
29
110
|
if (!isTTY) {
|
|
@@ -33,63 +114,47 @@ export async function getProjectName(args) {
|
|
|
33
114
|
return (await prompt("Project name: ")).trim() || "ipl-dashboard";
|
|
34
115
|
}
|
|
35
116
|
|
|
36
|
-
export async function
|
|
37
|
-
|
|
38
|
-
await consumeAllLines();
|
|
39
|
-
return (pipedLines[promptIndex++] || defaultUri || "").trim();
|
|
40
|
-
}
|
|
41
|
-
const answer = (await prompt(`MongoDB URI (press Enter for default): `)).trim();
|
|
42
|
-
return answer || defaultUri || "";
|
|
117
|
+
export async function getStorageBackend() {
|
|
118
|
+
return select("Storage backend:", ["MongoDB", "Google Sheets"], BACKEND_VALUES);
|
|
43
119
|
}
|
|
44
120
|
|
|
45
|
-
export async function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
121
|
+
export async function getConnectionUrl() {
|
|
122
|
+
const backend = await getStorageBackend();
|
|
123
|
+
if (backend === "google_sheets") {
|
|
124
|
+
if (!isTTY) {
|
|
125
|
+
await consumeAllLines();
|
|
126
|
+
return { backend, sheetId: (pipedLines[promptIndex++] || "").trim(), serviceAccountEmail: (pipedLines[promptIndex++] || "").trim(), privateKey: (pipedLines[promptIndex++] || "").trim() };
|
|
127
|
+
}
|
|
128
|
+
console.log("\n Google Sheets credentials:\n");
|
|
129
|
+
const sheetId = (await prompt(" Google Sheet ID: ")).trim();
|
|
130
|
+
const serviceAccountEmail = (await prompt(" Service account email: ")).trim();
|
|
131
|
+
const privateKey = (await prompt(" Private key (including -----BEGIN/END-----): ")).trim();
|
|
132
|
+
return { backend, sheetId, serviceAccountEmail, privateKey };
|
|
49
133
|
}
|
|
50
|
-
return (await prompt("IPL fantasy league URL (or press Enter to skip, can be set later): ")).trim();
|
|
51
|
-
}
|
|
52
134
|
|
|
53
|
-
|
|
135
|
+
const DEFAULT_MONGODB_URI = "mongodb+srv://admin:admin@cricket.ptjjrub.mongodb.net/?appName=cricket";
|
|
54
136
|
if (!isTTY) {
|
|
55
137
|
await consumeAllLines();
|
|
56
|
-
return (pipedLines[promptIndex++] || "
|
|
138
|
+
return { backend, uri: (pipedLines[promptIndex++] || DEFAULT_MONGODB_URI || "").trim() };
|
|
57
139
|
}
|
|
58
|
-
|
|
140
|
+
const answer = (await prompt("MongoDB URI (press Enter for default): ")).trim();
|
|
141
|
+
return { backend, uri: answer || DEFAULT_MONGODB_URI || "" };
|
|
59
142
|
}
|
|
60
143
|
|
|
61
|
-
export async function
|
|
144
|
+
export async function getLeagueUrl() {
|
|
62
145
|
if (!isTTY) {
|
|
63
146
|
await consumeAllLines();
|
|
64
|
-
return (pipedLines[promptIndex++] || "
|
|
147
|
+
return (pipedLines[promptIndex++] || "").trim();
|
|
65
148
|
}
|
|
66
|
-
return (await prompt("
|
|
149
|
+
return (await prompt("IPL fantasy league URL: ")).trim();
|
|
67
150
|
}
|
|
68
151
|
|
|
69
|
-
export async function
|
|
152
|
+
export async function getLeagueName() {
|
|
70
153
|
if (!isTTY) {
|
|
71
154
|
await consumeAllLines();
|
|
72
|
-
|
|
73
|
-
const teams = [];
|
|
74
|
-
for (let i = 0; i < count; i++) {
|
|
75
|
-
const line = pipedLines[promptIndex++] || "";
|
|
76
|
-
const parts = line.split(",").map((s) => s.trim());
|
|
77
|
-
teams.push({ id: i + 1, name: parts[0] || `Team ${i + 1}`, owner: parts[1] || parts[0] || `Team ${i + 1}` });
|
|
78
|
-
}
|
|
79
|
-
return teams;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const countAnswer = await prompt("How many teams in your league? (default: 9): ");
|
|
83
|
-
const count = Math.max(1, parseInt(countAnswer, 10) || 9);
|
|
84
|
-
|
|
85
|
-
console.log(`\nEnter ${count} team(s) — at minimum a name, optionally an owner:\n`);
|
|
86
|
-
const teams = [];
|
|
87
|
-
for (let i = 0; i < count; i++) {
|
|
88
|
-
const line = await prompt(` Team ${i + 1} (Name or "Name,Owner"): `);
|
|
89
|
-
const parts = line.split(",").map((s) => s.trim());
|
|
90
|
-
teams.push({ id: i + 1, name: parts[0] || `Team ${i + 1}`, owner: parts[1] || parts[0] || `Team ${i + 1}` });
|
|
155
|
+
return (pipedLines[promptIndex++] || "my_league").trim();
|
|
91
156
|
}
|
|
92
|
-
return
|
|
157
|
+
return (await prompt("League name (used as collection/table name, e.g. my_office_league): ")).trim() || "my_league";
|
|
93
158
|
}
|
|
94
159
|
|
|
95
160
|
export function close() {}
|
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
|
-
{
|
|
13
|
+
{ conn, leagueUrl, leagueName, teams, skipInstall = false },
|
|
14
14
|
) {
|
|
15
15
|
console.log(`\nCreating project at ${projectPath}...`);
|
|
16
16
|
|
|
@@ -34,15 +34,14 @@ export async function scaffoldProject(
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
await writeEnvFile(projectPath, {
|
|
37
|
-
|
|
37
|
+
conn,
|
|
38
38
|
leagueUrl,
|
|
39
|
-
collectionName,
|
|
40
39
|
leagueName,
|
|
41
40
|
});
|
|
42
|
-
await writeTeamData(projectPath, teams);
|
|
43
|
-
await writeLeagueData(projectPath, { leagueName, leagueUrl, teams });
|
|
41
|
+
await writeTeamData(projectPath, teams || []);
|
|
42
|
+
await writeLeagueData(projectPath, { leagueName, leagueUrl, teams: teams || [] });
|
|
44
43
|
await writeMatchPointsPlaceholder(projectPath);
|
|
45
|
-
await updatePackageJson(projectPath);
|
|
44
|
+
await updatePackageJson(projectPath, conn.backend);
|
|
46
45
|
|
|
47
46
|
for (const f of [
|
|
48
47
|
"app/api/ipl/live-snapshot.json",
|
|
@@ -57,15 +56,15 @@ export async function scaffoldProject(
|
|
|
57
56
|
console.log(" Running npm install...");
|
|
58
57
|
execSync("npm install", { cwd: projectPath, stdio: "inherit" });
|
|
59
58
|
|
|
60
|
-
if (
|
|
61
|
-
console.log(" Seeding league metadata
|
|
59
|
+
if (leagueName) {
|
|
60
|
+
console.log(" Seeding league metadata...");
|
|
62
61
|
try {
|
|
63
|
-
execSync(`npm run seed:league -- "${leagueName}" "${
|
|
62
|
+
execSync(`npm run seed:league -- "${leagueName}" "${leagueName}"`, {
|
|
64
63
|
cwd: projectPath,
|
|
65
64
|
stdio: "inherit",
|
|
66
65
|
});
|
|
67
66
|
} catch {
|
|
68
|
-
console.log(" League seed skipped (
|
|
67
|
+
console.log(" League seed skipped (storage may not be reachable yet)");
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
70
|
} else {
|
|
@@ -79,12 +78,15 @@ export async function scaffoldProject(
|
|
|
79
78
|
|
|
80
79
|
async function writeEnvFile(
|
|
81
80
|
projectPath,
|
|
82
|
-
{
|
|
81
|
+
{ conn, leagueUrl, leagueName },
|
|
83
82
|
) {
|
|
84
83
|
const envPath = join(projectPath, ".env");
|
|
85
84
|
const lines = [
|
|
86
|
-
`
|
|
87
|
-
`COLLECTION_NAME=${
|
|
85
|
+
`STORAGE_BACKEND=${conn.backend || "mongodb"}`,
|
|
86
|
+
`COLLECTION_NAME=${leagueName || "my_league"}`,
|
|
87
|
+
conn.backend === "google_sheets" ? `GOOGLE_SHEET_ID=${conn.sheetId || ""}` : `MONGODB_URI=${conn.uri || ""}`,
|
|
88
|
+
conn.backend === "google_sheets" ? `GOOGLE_SERVICE_ACCOUNT_EMAIL=${conn.serviceAccountEmail || ""}` : "",
|
|
89
|
+
conn.backend === "google_sheets" ? `GOOGLE_PRIVATE_KEY=${conn.privateKey || ""}` : "",
|
|
88
90
|
`IPL_LEAGUE_URL=${leagueUrl || ""}`,
|
|
89
91
|
`IPL_LEAGUE_NAME=${leagueName || ""}`,
|
|
90
92
|
`IPL_POST_SECRET=`,
|
|
@@ -94,7 +96,7 @@ async function writeEnvFile(
|
|
|
94
96
|
`IPL_API_LOG_PAYLOAD=0`,
|
|
95
97
|
`IPL_WRITE_SEED_DATA_FILE=1`,
|
|
96
98
|
``,
|
|
97
|
-
];
|
|
99
|
+
].filter(Boolean);
|
|
98
100
|
await writeFile(envPath, lines.join("\n"));
|
|
99
101
|
}
|
|
100
102
|
|
|
@@ -180,7 +182,7 @@ async function writeMatchPointsPlaceholder(projectPath) {
|
|
|
180
182
|
await writeFile(join(dir, "match-points.ts"), content);
|
|
181
183
|
}
|
|
182
184
|
|
|
183
|
-
async function updatePackageJson(projectPath) {
|
|
185
|
+
async function updatePackageJson(projectPath, storageBackend) {
|
|
184
186
|
const pkgPath = join(projectPath, "package.json");
|
|
185
187
|
try {
|
|
186
188
|
const raw = await readFile(pkgPath, "utf8");
|
|
@@ -189,6 +191,10 @@ async function updatePackageJson(projectPath) {
|
|
|
189
191
|
pkg.private = false;
|
|
190
192
|
delete pkg.scripts?.["generate:template"];
|
|
191
193
|
pkg.scripts["seed:league"] = "node scripts/seed-league.mjs";
|
|
194
|
+
if (storageBackend === "google_sheets" && !pkg.dependencies.googleapis) {
|
|
195
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
196
|
+
pkg.dependencies.googleapis = "^140.0.0";
|
|
197
|
+
}
|
|
192
198
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
193
199
|
} catch {}
|
|
194
200
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
getTransformOptions,
|
|
13
13
|
} from "./transform";
|
|
14
14
|
import { rawApiUsers } from "./data";
|
|
15
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
227
|
-
if (!db) return { configured: false, stored: false };
|
|
203
|
+
if (!storage.isConfigured()) return { configured: false, stored: false };
|
|
228
204
|
try {
|
|
229
|
-
await
|
|
230
|
-
|
|
231
|
-
{
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
78
|
-
const
|
|
70
|
+
const storageConfigured = storage.isConfigured();
|
|
71
|
+
const storageConnected = await storage.isConnected();
|
|
79
72
|
|
|
80
|
-
if (!
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
storageBackend: process.env.STORAGE_BACKEND || "mongodb",
|
|
81
|
+
reason: storageConfigured
|
|
82
|
+
? "Storage connection failed."
|
|
83
|
+
: "Storage is not configured.",
|
|
90
84
|
},
|
|
91
|
-
{ status:
|
|
85
|
+
{ status: storageConfigured ? 503 : 200 },
|
|
92
86
|
);
|
|
93
87
|
}
|
|
94
88
|
|
|
95
89
|
const [dashboardDocument, rawUsersDocument, transferDocuments] = await Promise.all([
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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:
|
|
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
|
|
@@ -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.");
|