start on migrating from Supabase to local Postgress, and passport.js for auth because CORS
This commit is contained in:
163
package-lock.json
generated
163
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -33,6 +34,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
@@ -2708,6 +2710,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
@@ -3393,6 +3405,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
@@ -3832,9 +3850,19 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -4056,6 +4084,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@@ -7467,7 +7510,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -7513,6 +7555,79 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -7646,7 +7761,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8511,6 +8625,20 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -9246,6 +9374,23 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@@ -10006,6 +10151,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -10128,6 +10279,12 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
|
||||
349
server.ts
349
server.ts
@@ -8,8 +8,10 @@ import dotenv from 'dotenv';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import { findUserByEmail, createUser, findUserById, findUserProfileById, updateUserPreferences, updateUserPassword, deleteUserById, checkTablesExist, getPoolStatus, saveRefreshToken, findUserByRefreshToken } from './src/services/db';
|
||||
import multer from 'multer';
|
||||
import * as db from './src/services/db';
|
||||
import { logger } from './src/services/logger';
|
||||
import { Profile, UserProfile } from './src/types';
|
||||
|
||||
// Load environment variables from a .env file at the root of your project
|
||||
dotenv.config();
|
||||
@@ -27,13 +29,27 @@ if (JWT_SECRET === 'your_super_secret_jwt_key_change_this') {
|
||||
logger.warn('Security Warning: JWT_SECRET is using a default, insecure value. Please set a strong secret in your .env file.');
|
||||
}
|
||||
|
||||
// --- Multer Configuration for File Uploads ---
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, storagePath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
// Create a unique filename to avoid conflicts
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
// --- Passport Local Strategy (for email/password login) ---
|
||||
passport.use(new LocalStrategy(
|
||||
{ usernameField: 'email' }, // Tell Passport to expect 'email' instead of 'username'
|
||||
async (email, password, done) => {
|
||||
try {
|
||||
// 1. Find the user in your PostgreSQL database by email.
|
||||
const user = await findUserByEmail(email);
|
||||
const user = await db.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
// User not found
|
||||
@@ -71,10 +87,10 @@ passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
||||
try {
|
||||
// The jwt_payload contains the data you put into the token during login (e.g., { id: user.id, email: user.email }).
|
||||
// We re-fetch the user from the database here to ensure they are still active and valid.
|
||||
const user = await findUserById(jwt_payload.id);
|
||||
const userProfile = await db.findUserProfileById(jwt_payload.id);
|
||||
|
||||
if (user) {
|
||||
return done(null, user); // User object will be available as req.user in protected routes
|
||||
if (userProfile) {
|
||||
return done(null, userProfile); // User profile object will be available as req.user in protected routes
|
||||
} else {
|
||||
logger.warn(`JWT authentication failed: user with ID ${jwt_payload.id} not found.`);
|
||||
return done(null, false); // User not found or invalid token
|
||||
@@ -85,6 +101,24 @@ passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
||||
}
|
||||
}));
|
||||
|
||||
// --- Middleware for Admin Role Check ---
|
||||
const isAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
// This middleware should run *after* passport.authenticate('jwt', ...)
|
||||
const userProfile = req.user as UserProfile; // The user object from JWT strategy includes the full UserProfile
|
||||
|
||||
// We need to fetch the profile to check the role.
|
||||
// The JWT payload only has id and email. Let's adjust the JWT strategy to include the role,
|
||||
// or fetch it here. For security, fetching it is better as roles can change.
|
||||
// Let's assume the profile is attached to req.user. The JWT strategy fetches the user, but not the profile.
|
||||
// Let's modify the JWT strategy to fetch the full user profile.
|
||||
if (userProfile && userProfile.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
logger.warn(`Admin access denied for user: ${userProfile?.id} (${userProfile?.user?.email})`);
|
||||
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
||||
}
|
||||
};
|
||||
|
||||
// --- API Routes ---
|
||||
|
||||
// --- Health & System Check Routes ---
|
||||
@@ -95,7 +129,7 @@ app.get('/api/health/ping', (req: Request, res: Response) => {
|
||||
app.get('/api/health/db-schema', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
|
||||
const missingTables = await checkTablesExist(requiredTables);
|
||||
const missingTables = await db.checkTablesExist(requiredTables);
|
||||
|
||||
if (missingTables.length > 0) {
|
||||
return res.status(500).json({ success: false, message: `Database schema check failed. Missing tables: ${missingTables.join(', ')}.` });
|
||||
@@ -121,7 +155,7 @@ app.get('/api/health/storage', async (req: Request, res: Response) => {
|
||||
|
||||
app.get('/api/health/db-pool', (req: Request, res: Response) => {
|
||||
try {
|
||||
const status = getPoolStatus();
|
||||
const status = db.getPoolStatus();
|
||||
// A healthy pool should ideally have 0 waiting clients. A small number might be acceptable under load.
|
||||
const isHealthy = status.waitingCount < 5; // Arbitrary threshold for "healthy"
|
||||
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
|
||||
@@ -138,6 +172,219 @@ app.get('/api/health/db-pool', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Public Data Routes ---
|
||||
|
||||
app.get('/api/flyers', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const flyers = await db.getFlyers();
|
||||
res.json(flyers);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching flyers in /api/flyers:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/master-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const masterItems = await db.getAllMasterItems();
|
||||
res.json(masterItems);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching master items in /api/master-items:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/flyers/:id/items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const flyerId = parseInt(req.params.id, 10);
|
||||
const items = await db.getFlyerItems(flyerId);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching flyer items in /api/flyers/:id/items:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/flyer-items/batch-fetch', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { flyerIds } = req.body;
|
||||
if (!Array.isArray(flyerIds)) {
|
||||
return res.status(400).json({ message: 'flyerIds must be an array.' });
|
||||
}
|
||||
try {
|
||||
const items = await db.getFlyerItemsForFlyers(flyerIds);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/flyer-items/batch-count', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { flyerIds } = req.body;
|
||||
if (!Array.isArray(flyerIds)) {
|
||||
return res.status(400).json({ message: 'flyerIds must be an array.' });
|
||||
}
|
||||
try {
|
||||
const count = await db.countFlyerItemsForFlyers(flyerIds);
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
// --- Flyer Processing Route ---
|
||||
|
||||
app.post('/api/flyers/process', upload.single('flyerImage'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Flyer image file is required.' });
|
||||
}
|
||||
|
||||
// The rest of the data is sent as JSON in a 'data' field
|
||||
const { checksum, originalFileName, extractedData } = JSON.parse(req.body.data);
|
||||
|
||||
// 1. Check for duplicates
|
||||
const existingFlyer = await db.findFlyerByChecksum(checksum);
|
||||
if (existingFlyer) {
|
||||
logger.info(`Processing skipped for duplicate flyer checksum: ${checksum}`);
|
||||
return res.status(200).json({ message: 'Duplicate flyer, processing skipped.', flyer: existingFlyer });
|
||||
}
|
||||
|
||||
// 2. Prepare data for database insertion
|
||||
const flyerData = {
|
||||
file_name: originalFileName,
|
||||
image_url: `/assets/${req.file.filename}`, // URL relative to the server's public path
|
||||
checksum: checksum,
|
||||
store_name: extractedData.store_name,
|
||||
valid_from: extractedData.valid_from,
|
||||
valid_to: extractedData.valid_to,
|
||||
store_address: extractedData.store_address,
|
||||
};
|
||||
|
||||
// 3. Create flyer and items in a transaction
|
||||
const newFlyer = await db.createFlyerAndItems(flyerData, extractedData.items);
|
||||
logger.info(`Successfully processed and saved new flyer: ${originalFileName}`);
|
||||
res.status(201).json({ message: 'Flyer processed successfully.', flyer: newFlyer });
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error in /api/flyers/process endpoint:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Store Logo Upload Route (Protected) ---
|
||||
|
||||
app.post('/api/stores/:id/logo', passport.authenticate('jwt', { session: false }), upload.single('logoImage'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const storeId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Logo image file is required.' });
|
||||
}
|
||||
|
||||
const logoUrl = `/assets/${req.file.filename}`;
|
||||
await db.updateStoreLogo(storeId, logoUrl);
|
||||
|
||||
res.status(200).json({ message: 'Store logo updated successfully.', logoUrl });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Watched Items Routes (Protected) ---
|
||||
|
||||
app.get('/api/watched-items', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string; email: string };
|
||||
try {
|
||||
const items = await db.getWatchedItems(user.id);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/watched-items', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string; email: string };
|
||||
const { itemName, category } = req.body;
|
||||
try {
|
||||
const newItem = await db.addWatchedItem(user.id, itemName, category);
|
||||
res.status(201).json(newItem);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/watched-items/:masterItemId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string; email: string };
|
||||
const { masterItemId } = req.params;
|
||||
try {
|
||||
await db.removeWatchedItem(user.id, parseInt(masterItemId, 10));
|
||||
res.status(204).send(); // No Content
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Shopping List Routes (Protected) ---
|
||||
|
||||
app.get('/api/shopping-lists', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string; email: string };
|
||||
try {
|
||||
const lists = await db.getShoppingLists(user.id);
|
||||
res.json(lists);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shopping-lists', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string; email: string };
|
||||
const { name } = req.body;
|
||||
try {
|
||||
const newList = await db.createShoppingList(user.id, name);
|
||||
res.status(201).json(newList);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/shopping-lists/:listId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string; email: string };
|
||||
const { listId } = req.params;
|
||||
try {
|
||||
await db.deleteShoppingList(parseInt(listId, 10), user.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shopping-lists/:listId/items', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const item = req.body;
|
||||
try {
|
||||
const newItem = await db.addShoppingListItem(parseInt(req.params.listId, 10), item);
|
||||
res.status(201).json(newItem);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/shopping-lists/items/:itemId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const updates = req.body;
|
||||
try {
|
||||
const updatedItem = await db.updateShoppingListItem(parseInt(req.params.itemId, 10), updates);
|
||||
res.json(updatedItem);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/shopping-lists/items/:itemId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await db.removeShoppingListItem(parseInt(req.params.itemId, 10));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Authentication Routes ---
|
||||
|
||||
// Registration Route
|
||||
@@ -149,7 +396,7 @@ app.post('/api/auth/register', async (req: Request, res: Response, next: NextFun
|
||||
}
|
||||
|
||||
try {
|
||||
const existingUser = await findUserByEmail(email);
|
||||
const existingUser = await db.findUserByEmail(email);
|
||||
if (existingUser) {
|
||||
logger.warn(`Registration attempt for existing email: ${email}`);
|
||||
return res.status(409).json({ message: 'User with that email already exists.' });
|
||||
@@ -160,7 +407,7 @@ app.post('/api/auth/register', async (req: Request, res: Response, next: NextFun
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
logger.info(`Hashing password for new user: ${email}`);
|
||||
|
||||
const newUser = await createUser(email, hashedPassword);
|
||||
const newUser = await db.createUser(email, hashedPassword);
|
||||
logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.id})`);
|
||||
|
||||
// Immediately log in the user by issuing a JWT
|
||||
@@ -169,7 +416,7 @@ app.post('/api/auth/register', async (req: Request, res: Response, next: NextFun
|
||||
|
||||
// Generate and save a refresh token
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
await saveRefreshToken(newUser.id, refreshToken);
|
||||
await db.saveRefreshToken(newUser.id, refreshToken);
|
||||
|
||||
// Send the refresh token in a secure, HttpOnly cookie
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
@@ -206,7 +453,7 @@ app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) =>
|
||||
|
||||
// Generate and save a refresh token
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
saveRefreshToken(typedUser.id, refreshToken).then(() => {
|
||||
db.saveRefreshToken(typedUser.id, refreshToken).then(() => {
|
||||
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
|
||||
|
||||
// Send the refresh token in a secure, HttpOnly cookie
|
||||
@@ -231,7 +478,7 @@ app.post('/api/auth/refresh-token', async (req: Request, res: Response) => {
|
||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||
}
|
||||
|
||||
const user = await findUserByRefreshToken(refreshToken);
|
||||
const user = await db.findUserByRefreshToken(refreshToken);
|
||||
if (!user) {
|
||||
return res.status(403).json({ message: 'Invalid refresh token.' });
|
||||
}
|
||||
@@ -251,7 +498,7 @@ app.get('/api/users/profile', passport.authenticate('jwt', { session: false }),
|
||||
logger.info(`Profile requested for user: ${authenticatedUser.email}`);
|
||||
|
||||
try {
|
||||
const profile = await findUserProfileById(authenticatedUser.id);
|
||||
const profile = await db.findUserProfileById(authenticatedUser.id);
|
||||
if (!profile) {
|
||||
logger.warn(`No profile found for authenticated user ID: ${authenticatedUser.id}`);
|
||||
return res.status(404).json({ message: 'Profile not found for this user.' });
|
||||
@@ -275,7 +522,7 @@ app.put('/api/users/profile/preferences', passport.authenticate('jwt', { session
|
||||
logger.info(`Preferences update requested for user: ${authenticatedUser.email}`, { newPreferences });
|
||||
|
||||
try {
|
||||
const updatedProfile = await updateUserPreferences(authenticatedUser.id, newPreferences);
|
||||
const updatedProfile = await db.updateUserPreferences(authenticatedUser.id, newPreferences);
|
||||
res.json(updatedProfile);
|
||||
} catch (error) {
|
||||
logger.error('Error updating preferences in /api/users/profile/preferences:', { error });
|
||||
@@ -297,7 +544,7 @@ app.put('/api/users/profile/password', passport.authenticate('jwt', { session: f
|
||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
logger.info(`Hashing new password for user: ${authenticatedUser.email}`);
|
||||
|
||||
await updateUserPassword(authenticatedUser.id, hashedPassword);
|
||||
await db.updateUserPassword(authenticatedUser.id, hashedPassword);
|
||||
|
||||
logger.info(`Successfully updated password for user: ${authenticatedUser.email}`);
|
||||
res.status(200).json({ message: 'Password updated successfully.' });
|
||||
@@ -318,7 +565,7 @@ app.delete('/api/users/account', passport.authenticate('jwt', { session: false }
|
||||
|
||||
try {
|
||||
// 1. Fetch the user from DB to get their current password hash for verification
|
||||
const userWithHash = await findUserByEmail(authenticatedUser.email);
|
||||
const userWithHash = await db.findUserByEmail(authenticatedUser.email);
|
||||
if (!userWithHash) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
@@ -331,7 +578,7 @@ app.delete('/api/users/account', passport.authenticate('jwt', { session: false }
|
||||
}
|
||||
|
||||
// 3. If password matches, delete the user. The `ON DELETE CASCADE` in your schema will clean up related data.
|
||||
await deleteUserById(authenticatedUser.id);
|
||||
await db.deleteUserById(authenticatedUser.id);
|
||||
logger.warn(`User account deleted successfully: ${authenticatedUser.email}`);
|
||||
res.status(200).json({ message: 'Account deleted successfully.' });
|
||||
} catch (error) {
|
||||
@@ -340,6 +587,74 @@ app.delete('/api/users/account', passport.authenticate('jwt', { session: false }
|
||||
}
|
||||
});
|
||||
|
||||
// --- Admin Routes (Protected for Admins) ---
|
||||
|
||||
app.get('/api/admin/corrections', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const corrections = await db.getSuggestedCorrections();
|
||||
res.json(corrections);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching corrections in /api/admin/corrections:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/stats', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await db.getApplicationStats();
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching application stats in /api/admin/stats:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/stats/daily', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const dailyStats = await db.getDailyStatsForLast30Days();
|
||||
res.json(dailyStats);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching daily stats in /api/admin/stats/daily:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/corrections/:id/approve', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
await db.approveCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||
} catch (error) {
|
||||
logger.error(`Error approving correction ${correctionId}:`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/corrections/:id/reject', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
await db.rejectCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||
} catch (error) {
|
||||
logger.error(`Error rejecting correction ${correctionId}:`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/admin/corrections/:id', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
const { suggested_value } = req.body;
|
||||
if (!suggested_value) {
|
||||
return res.status(400).json({ message: 'A new suggested_value is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedCorrection = await db.updateSuggestedCorrection(correctionId, suggested_value);
|
||||
res.status(200).json(updatedCorrection);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Error Handling and Server Startup ---
|
||||
|
||||
// Basic error handling middleware
|
||||
|
||||
181
src/App.tsx
181
src/App.tsx
@@ -12,7 +12,8 @@ import { isImageAFlyer, extractCoreDataFromImage, extractAddressFromImage, extra
|
||||
import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User, UserProfile } from './types';
|
||||
import { BulkImporter } from './components/BulkImporter';
|
||||
import { PriceHistoryChart } from './components/PriceHistoryChart'; // This import seems to have a supabase dependency, but the component is not provided. Assuming it will be updated separately.
|
||||
import { supabase, uploadFlyerImage, createFlyerRecord, saveFlyerItems, getFlyers, getFlyerItems, findFlyerByChecksum, getWatchedItems, addWatchedItem, getAllMasterItems, getFlyerItemsForFlyers, countFlyerItemsForFlyers, getUserProfile, updateUserPreferences, removeWatchedItem, getShoppingLists, createShoppingList, addShoppingListItem, updateShoppingListItem, removeShoppingListItem, deleteShoppingList, uploadLogoAndUpdateStore } from './services/supabaseClient';
|
||||
import { supabase } from './services/supabaseClient'; // This will be removed shortly.
|
||||
import { getAuthenticatedUserProfile, fetchFlyers as apiFetchFlyers, fetchMasterItems as apiFetchMasterItems, fetchWatchedItems as apiFetchWatchedItems, addWatchedItem as apiAddWatchedItem, removeWatchedItem as apiRemoveWatchedItem, fetchShoppingLists as apiFetchShoppingLists, createShoppingList as apiCreateShoppingList, deleteShoppingList as apiDeleteShoppingList, addShoppingListItem as apiAddShoppingListItem, updateShoppingListItem as apiUpdateShoppingListItem, removeShoppingListItem as apiRemoveShoppingListItem, processFlyerFile, fetchFlyerItems as apiFetchFlyerItems, fetchFlyerItemsForFlyers as apiFetchFlyerItemsForFlyers, countFlyerItemsForFlyers as apiCountFlyerItemsForFlyers, uploadLogoAndUpdateStore } from './services/apiClient'; // updateUserPreferences is no longer called directly from App.tsx
|
||||
import { FlyerList } from './components/FlyerList';
|
||||
import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer';
|
||||
import { ProcessingStatus } from './components/ProcessingStatus';
|
||||
@@ -27,8 +28,8 @@ import { AdminPage } from './pages/AdminPage';
|
||||
import { AdminRoute } from './components/AdminRoute';
|
||||
import { CorrectionsPage } from './pages/CorrectionsPage';
|
||||
import { WatchedItemsList } from './components/WatchedItemsList';
|
||||
import { AdminStatsPage } from './pages/AdminStatsPage';
|
||||
import { ResetPasswordPage } from './pages/ResetPasswordPage';
|
||||
import { getAuthenticatedUserProfile } from './services/apiClient'; // updateUserPreferences is no longer called directly from App.tsx
|
||||
|
||||
// Define a more descriptive type for the authentication status.
|
||||
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
|
||||
@@ -61,8 +62,7 @@ function App() {
|
||||
errors: { fileName: string; message: string }[];
|
||||
} | null>(null);
|
||||
|
||||
const isDbConnected = !!supabase;
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false); // This will now be controlled by a simple timer.
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
@@ -110,14 +110,9 @@ function App() {
|
||||
// Effect to mark the app as "ready" for data fetching.
|
||||
// This replaces the onReady callback from the now-removed SystemCheck component.
|
||||
useEffect(() => {
|
||||
if (!isDbConnected) {
|
||||
setIsReady(true);
|
||||
} else {
|
||||
// A short delay to allow other components to initialize.
|
||||
const timer = setTimeout(() => setIsReady(true), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isDbConnected]);
|
||||
const timer = setTimeout(() => setIsReady(true), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// This is the login handler that will be passed to the ProfileManager component.
|
||||
const handleLoginSuccess = async (loggedInUser: User, token: string) => {
|
||||
@@ -140,25 +135,24 @@ function App() {
|
||||
// The toggleDarkMode and toggleUnitSystem functions are now handled within ProfileManager.tsx.
|
||||
// App.tsx will react to changes in profile.preferences via its useEffects.
|
||||
// The Header component will no longer receive these props directly.
|
||||
|
||||
const fetchFlyers = useCallback(async () => {
|
||||
if (!supabase) return;
|
||||
|
||||
const fetchFlyers = useCallback(async () => { // Renamed from apiFetchFlyers to avoid conflict
|
||||
try {
|
||||
const allFlyers = await getFlyers();
|
||||
const allFlyers = await apiFetchFlyers();
|
||||
setFlyers(allFlyers);
|
||||
} catch(e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
setError(errorMessage);
|
||||
setError(`Could not fetch flyers: ${errorMessage}`);
|
||||
}
|
||||
}, []); // No dependencies on user or profile, as this is general data
|
||||
|
||||
const fetchWatchedItems = useCallback(async (userId: string | undefined) => {
|
||||
if (!supabase || !userId) {
|
||||
const fetchWatchedItems = useCallback(async () => {
|
||||
if (!user) { // Check for authenticated user
|
||||
setWatchedItems([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const items = await getWatchedItems(userId);
|
||||
const items = await apiFetchWatchedItems();
|
||||
setWatchedItems(items);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
@@ -166,14 +160,14 @@ function App() {
|
||||
}
|
||||
}, []); // No dependencies on user or profile, as this is general data
|
||||
|
||||
const fetchShoppingLists = useCallback(async (userId: string | undefined) => {
|
||||
if (!supabase || !userId) {
|
||||
const fetchShoppingLists = useCallback(async () => {
|
||||
if (!user) { // Check for authenticated user
|
||||
setShoppingLists([]);
|
||||
setActiveListId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const lists = await getShoppingLists(userId);
|
||||
const lists = await apiFetchShoppingLists();
|
||||
setShoppingLists(lists);
|
||||
if (lists.length > 0 && !activeListId) {
|
||||
setActiveListId(lists[0].id);
|
||||
@@ -187,13 +181,12 @@ function App() {
|
||||
}, [activeListId]); // activeListId is a dependency for managing the active list
|
||||
|
||||
const fetchMasterItems = useCallback(async () => {
|
||||
if (!supabase) return;
|
||||
try {
|
||||
const items = await getAllMasterItems();
|
||||
const items = await apiFetchMasterItems();
|
||||
setMasterItems(items);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
setError(`Could not fetch master item list: ${errorMessage}`);
|
||||
setError(`Could not fetch master items: ${errorMessage}`);
|
||||
}
|
||||
}, []); // No dependencies on user or profile, as this is general data
|
||||
|
||||
@@ -228,18 +221,18 @@ function App() {
|
||||
// Effect to fetch user-specific data once authenticated.
|
||||
useEffect(() => {
|
||||
if (authStatus === 'AUTHENTICATED' && user) {
|
||||
fetchWatchedItems(user.id);
|
||||
fetchShoppingLists(user.id);
|
||||
fetchWatchedItems();
|
||||
fetchShoppingLists();
|
||||
}
|
||||
}, [authStatus, user, fetchWatchedItems, fetchShoppingLists]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && isDbConnected) {
|
||||
if (isReady) {
|
||||
fetchFlyers();
|
||||
fetchMasterItems();
|
||||
}
|
||||
}, [isDbConnected, isReady, fetchFlyers, fetchMasterItems]);
|
||||
}, [isReady, fetchFlyers, fetchMasterItems]);
|
||||
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
@@ -259,10 +252,8 @@ function App() {
|
||||
setError(null);
|
||||
setFlyerItems([]); // Clear previous items
|
||||
|
||||
if (!supabase) return;
|
||||
|
||||
try {
|
||||
const items = await getFlyerItems(flyer.id);
|
||||
const items = await apiFetchFlyerItems(flyer.id);
|
||||
setFlyerItems(items);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
@@ -278,7 +269,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const findActiveDeals = async () => {
|
||||
if (!isDbConnected || !isReady || flyers.length === 0 || watchedItems.length === 0) {
|
||||
if (!isReady || flyers.length === 0 || watchedItems.length === 0) {
|
||||
setActiveDeals([]);
|
||||
return;
|
||||
}
|
||||
@@ -307,7 +298,7 @@ function App() {
|
||||
}
|
||||
|
||||
const validFlyerIds = validFlyers.map(f => f.id);
|
||||
const allItems = await getFlyerItemsForFlyers(validFlyerIds);
|
||||
const allItems = await apiFetchFlyerItemsForFlyers(validFlyerIds);
|
||||
|
||||
const watchedItemIds = new Set(watchedItems.map(item => item.id));
|
||||
const dealItemsRaw = allItems.filter(item =>
|
||||
@@ -336,11 +327,11 @@ function App() {
|
||||
};
|
||||
|
||||
findActiveDeals();
|
||||
}, [flyers, watchedItems, isDbConnected, isReady]);
|
||||
}, [flyers, watchedItems, isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateTotalActiveItems = async () => {
|
||||
if (!isDbConnected || !isReady || flyers.length === 0) {
|
||||
if (!isReady || flyers.length === 0) {
|
||||
setTotalActiveItems(0);
|
||||
return;
|
||||
}
|
||||
@@ -367,7 +358,7 @@ function App() {
|
||||
}
|
||||
|
||||
const validFlyerIds = validFlyers.map(f => f.id);
|
||||
const totalCount = await countFlyerItemsForFlyers(validFlyerIds);
|
||||
const totalCount = await apiCountFlyerItemsForFlyers(validFlyerIds);
|
||||
setTotalActiveItems(totalCount);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
@@ -377,9 +368,9 @@ function App() {
|
||||
};
|
||||
|
||||
calculateTotalActiveItems();
|
||||
}, [flyers, isDbConnected, isReady]);
|
||||
}, [flyers, isReady]);
|
||||
|
||||
const processFiles = async (files: File[], checksum: string, originalFileName: string, updateStage?: (index: number, updates: Partial<ProcessingStage>) => void) => {
|
||||
const processAndUploadFlyer = async (files: File[], checksum: string, originalFileName: string, updateStage?: (index: number, updates: Partial<ProcessingStage>) => void) => {
|
||||
let stageIndex = 0;
|
||||
|
||||
// Stage: Validating Flyer
|
||||
@@ -459,54 +450,31 @@ function App() {
|
||||
updateStage?.(stageIndex++, { status: 'error', detail: '(Skipped)' }); // stageIndex is now 5
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
throw new Error("Cannot process flyer: Supabase client not initialized.");
|
||||
}
|
||||
|
||||
// Stage: Uploading Flyer Image
|
||||
// Stage: Uploading and Saving Data to Backend
|
||||
updateStage?.(stageIndex, { status: 'in-progress' });
|
||||
const imageUrl = await withTimeout(uploadFlyerImage(files[0]), 30000);
|
||||
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 6
|
||||
|
||||
// Stage: Creating Database Record
|
||||
updateStage?.(stageIndex, { status: 'in-progress' });
|
||||
const newFlyer = await withTimeout(createFlyerRecord(originalFileName, imageUrl, checksum, store_name, valid_from, valid_to, storeAddress), 10000);
|
||||
if (!newFlyer) {
|
||||
throw new Error("Could not create a record for the new flyer.");
|
||||
const backendResponse = await processFlyerFile(files[0], checksum, originalFileName, { store_name, valid_from, valid_to, items: extractedItems, store_address: storeAddress });
|
||||
updateStage?.(stageIndex, { status: 'completed', detail: backendResponse.message });
|
||||
|
||||
// Fire-and-forget logo upload if a logo was extracted and the store doesn't already have one.
|
||||
if (storeLogoBase64 && backendResponse.flyer.store_id && !backendResponse.flyer.store?.logo_url) {
|
||||
const logoFile = await (await fetch(storeLogoBase64)).blob();
|
||||
uploadLogoAndUpdateStore(backendResponse.flyer.store_id, new File([logoFile], 'logo.png', { type: 'image/png' }))
|
||||
.catch(e => logger.warn("Non-critical error: Failed to upload store logo.", { error: e }));
|
||||
}
|
||||
|
||||
// Upload logo if extracted and if the store doesn't have one already.
|
||||
// This is a non-critical, fire-and-forget task.
|
||||
if (storeLogoBase64 && newFlyer.store_id && !newFlyer.store?.logo_url) {
|
||||
uploadLogoAndUpdateStore(newFlyer.store_id, storeLogoBase64);
|
||||
}
|
||||
|
||||
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 7
|
||||
|
||||
// Stage: Saving Items to Database
|
||||
updateStage?.(stageIndex, { status: 'in-progress' });
|
||||
const savedItems = await withTimeout(saveFlyerItems(extractedItems, newFlyer.id), 20000);
|
||||
updateStage?.(stageIndex, { status: 'completed' });
|
||||
|
||||
return { newFlyer, items: savedItems };
|
||||
return { newFlyer: backendResponse.flyer, items: [] }; // Items are now saved on backend, return empty array
|
||||
};
|
||||
|
||||
const setupProcessingStages = (isPdf: boolean) => {
|
||||
const pendingStatus: StageStatus = 'pending';
|
||||
const isDbAvailable = !!supabase;
|
||||
|
||||
const baseStages: ProcessingStage[] = [
|
||||
...(isDbAvailable ? [{ name: 'Checking for Duplicates', status: pendingStatus, critical: true }] : []),
|
||||
{ name: 'Validating Flyer', status: pendingStatus, critical: true },
|
||||
{ name: 'Extracting Store Name & Sale Dates', status: pendingStatus, critical: true },
|
||||
{ name: 'Extracting All Items from Flyer', status: pendingStatus, critical: true },
|
||||
{ name: 'Extracting Store Address', status: pendingStatus, critical: false },
|
||||
{ name: 'Extracting Store Logo', status: pendingStatus, critical: false },
|
||||
...(isDbAvailable ? [
|
||||
{ name: 'Uploading Flyer Image', status: pendingStatus, critical: true },
|
||||
{ name: 'Creating Database Record', status: pendingStatus, critical: true },
|
||||
{ name: 'Saving Items to Database', status: pendingStatus, critical: true },
|
||||
] : []),
|
||||
{ name: 'Uploading and Saving to Database', status: pendingStatus, critical: true },
|
||||
];
|
||||
if (isPdf) {
|
||||
return [
|
||||
@@ -524,13 +492,6 @@ function App() {
|
||||
resetState();
|
||||
setIsProcessing(true);
|
||||
setProcessingProgress(0);
|
||||
setError(null);
|
||||
|
||||
if (!supabase) {
|
||||
setError("A database connection is required to process flyers.");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
processed: [] as string[],
|
||||
@@ -577,23 +538,11 @@ function App() {
|
||||
filesToProcess = [originalFile];
|
||||
}
|
||||
|
||||
if (supabase) {
|
||||
updateStage(currentStageIndex, { status: 'in-progress' });
|
||||
checksum = await generateFileChecksum(originalFile);
|
||||
const existing = await findFlyerByChecksum(checksum);
|
||||
if (existing) {
|
||||
logger.info(`Skipping duplicate file: ${originalFile.name}`);
|
||||
summary.skipped.push(originalFile.name);
|
||||
updateStage(currentStageIndex, { status: 'completed', detail: '(Duplicate)' });
|
||||
setProcessingProgress(((i + 1) / files.length) * 100);
|
||||
continue;
|
||||
}
|
||||
updateStage(currentStageIndex++, { status: 'completed' });
|
||||
}
|
||||
checksum = await generateFileChecksum(originalFile);
|
||||
|
||||
const processFilesUpdateStage = (idx: number, updates: Partial<ProcessingStage>) => updateStage(idx + currentStageIndex, updates);
|
||||
const processAndUploadUpdateStage = (idx: number, updates: Partial<ProcessingStage>) => updateStage(idx + currentStageIndex, updates);
|
||||
|
||||
await processFiles(filesToProcess, checksum, originalFile.name, processFilesUpdateStage);
|
||||
await processAndUploadFlyer(filesToProcess, checksum, originalFile.name, processAndUploadUpdateStage);
|
||||
summary.processed.push(originalFile.name);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
@@ -623,9 +572,9 @@ function App() {
|
||||
}, [resetState, fetchFlyers, masterItems, fetchMasterItems]);
|
||||
|
||||
const handleAddWatchedItem = useCallback(async (itemName: string, category: string) => {
|
||||
if (!supabase || !user) return;
|
||||
if (!user) return;
|
||||
try {
|
||||
const updatedOrNewItem = await addWatchedItem(user.id, itemName, category);
|
||||
const updatedOrNewItem = await apiAddWatchedItem(itemName, category);
|
||||
setWatchedItems(prevItems => {
|
||||
const itemExists = prevItems.some(item => item.id === updatedOrNewItem.id);
|
||||
if (!itemExists) {
|
||||
@@ -637,14 +586,14 @@ function App() {
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
setError(`Could not add watched item: ${errorMessage}`);
|
||||
await fetchWatchedItems(user?.id);
|
||||
await fetchWatchedItems();
|
||||
}
|
||||
}, [user, fetchWatchedItems]);
|
||||
|
||||
const handleRemoveWatchedItem = useCallback(async (masterItemId: number) => {
|
||||
if (!supabase || !user) return;
|
||||
if (!user) return;
|
||||
try {
|
||||
await removeWatchedItem(user.id, masterItemId);
|
||||
await apiRemoveWatchedItem(masterItemId);
|
||||
setWatchedItems(prevItems => prevItems.filter(item => item.id !== masterItemId));
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
@@ -656,7 +605,7 @@ function App() {
|
||||
const handleCreateList = useCallback(async (name: string) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const newList = await createShoppingList(user.id, name);
|
||||
const newList = await apiCreateShoppingList(name);
|
||||
setShoppingLists(prev => [...prev, newList]);
|
||||
setActiveListId(newList.id);
|
||||
} catch (e) {
|
||||
@@ -665,9 +614,9 @@ function App() {
|
||||
}
|
||||
}, [user]); // Changed dependency from `session` to `user`
|
||||
const handleDeleteList = useCallback(async (listId: number) => {
|
||||
if (!user) return; // `user` is correctly used here
|
||||
try { // The user variable is not used here, but the check is important for authorization context
|
||||
await deleteShoppingList(listId);
|
||||
if (!user) return;
|
||||
try {
|
||||
await apiDeleteShoppingList(listId);
|
||||
const newLists = shoppingLists.filter(l => l.id !== listId);
|
||||
setShoppingLists(newLists);
|
||||
if (activeListId === listId) {
|
||||
@@ -680,9 +629,9 @@ function App() {
|
||||
}, [user, shoppingLists, activeListId]);
|
||||
|
||||
const handleAddShoppingListItem = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => {
|
||||
if (!user) return; // `user` is correctly used here
|
||||
try { // The user variable is not used here, but the check is important for authorization context
|
||||
const newItem = await addShoppingListItem(listId, item);
|
||||
if (!user) return;
|
||||
try {
|
||||
const newItem = await apiAddShoppingListItem(listId, item);
|
||||
setShoppingLists(prevLists => prevLists.map(list => {
|
||||
if (list.id === listId) {
|
||||
// Avoid adding duplicates to the state if it's already there
|
||||
@@ -699,9 +648,9 @@ function App() {
|
||||
}, [user, activeListId]); // Added activeListId to dependencies
|
||||
|
||||
const handleUpdateShoppingListItem = useCallback(async (itemId: number, updates: Partial<ShoppingListItem>) => {
|
||||
if (!user || !activeListId) return; // `user` is correctly used here
|
||||
try { // The user variable is not used here, but the check is important for authorization context
|
||||
const updatedItem = await updateShoppingListItem(itemId, updates);
|
||||
if (!user || !activeListId) return;
|
||||
try {
|
||||
const updatedItem = await apiUpdateShoppingListItem(itemId, updates);
|
||||
setShoppingLists(prevLists => prevLists.map(list => {
|
||||
if (list.id === activeListId) {
|
||||
return { ...list, items: list.items.map(i => i.id === itemId ? updatedItem : i) };
|
||||
@@ -715,10 +664,9 @@ function App() {
|
||||
}, [user, activeListId]); // Changed dependency from `session` to `user`
|
||||
|
||||
const handleRemoveShoppingListItem = useCallback(async (itemId: number) => {
|
||||
if (!user || !activeListId) return; // Changed check from `session` to `user`
|
||||
if (!user || !activeListId) return;
|
||||
try {
|
||||
// The user variable is not used here, but the check is important for authorization context
|
||||
await removeShoppingListItem(itemId);
|
||||
await apiRemoveShoppingListItem(itemId);
|
||||
setShoppingLists(prevLists => prevLists.map(list => {
|
||||
if (list.id === activeListId) {
|
||||
return { ...list, items: list.items.filter(i => i.id !== itemId) };
|
||||
@@ -782,7 +730,7 @@ function App() {
|
||||
|
||||
<div className="lg:col-span-1 flex flex-col space-y-6">
|
||||
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.id || null} />
|
||||
{isReady && isDbConnected && (
|
||||
{isReady && (
|
||||
<BulkImporter
|
||||
onProcess={handleProcessFiles}
|
||||
isProcessing={isProcessing}
|
||||
@@ -842,7 +790,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1 flex-col space-y-6">
|
||||
{isDbConnected && (
|
||||
{isReady && (
|
||||
<>
|
||||
<ShoppingListComponent
|
||||
user={user}
|
||||
@@ -879,6 +827,7 @@ function App() {
|
||||
<Route element={<AdminRoute profile={profile} />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/admin/corrections" element={<CorrectionsPage />} />
|
||||
<Route path="/admin/stats" element={<AdminStatsPage />} />
|
||||
</Route>
|
||||
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { SuggestedCorrection, MasterGroceryItem } from '../types';
|
||||
import { approveCorrection, rejectCorrection } from '../services/supabaseClient';
|
||||
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../types';
|
||||
import { approveCorrection, rejectCorrection, updateSuggestedCorrection } from '../services/apiClient';
|
||||
import { logger } from '../services/logger';
|
||||
import { CheckIcon } from './icons/CheckIcon';
|
||||
import { XMarkIcon } from './icons/XMarkIcon';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { PencilIcon } from './icons/PencilIcon';
|
||||
|
||||
interface CorrectionRowProps {
|
||||
correction: SuggestedCorrection;
|
||||
masterItems: MasterGroceryItem[];
|
||||
categories: Category[];
|
||||
onProcessed: (correctionId: number) => void;
|
||||
}
|
||||
|
||||
export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction, masterItems, onProcessed }) => {
|
||||
export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction, masterItems, categories, onProcessed }) => {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editableValue, setEditableValue] = useState(correction.suggested_value);
|
||||
const [currentCorrection, setCurrentCorrection] = useState(correction);
|
||||
const [actionToConfirm, setActionToConfirm] = useState<'approve' | 'reject' | null>(null);
|
||||
|
||||
// Helper to make the suggested value more readable for the admin.
|
||||
@@ -33,6 +38,11 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction, master
|
||||
const item = masterItems.find(mi => mi.id === masterItemId);
|
||||
return item ? `${item.name} (ID: ${masterItemId})` : `Unknown Item (ID: ${masterItemId})`;
|
||||
}
|
||||
if (correction_type === 'ITEM_IS_MISCATEGORIZED') {
|
||||
const categoryId = parseInt(suggested_value, 10);
|
||||
const category = categories.find(c => c.id === categoryId);
|
||||
return category ? `${category.name} (ID: ${categoryId})` : `Unknown Category (ID: ${categoryId})`;
|
||||
}
|
||||
return suggested_value;
|
||||
};
|
||||
|
||||
@@ -44,11 +54,11 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction, master
|
||||
setError(null);
|
||||
try {
|
||||
if (actionToConfirm === 'approve') {
|
||||
await approveCorrection(correction.id);
|
||||
logger.info(`Correction ${correction.id} approved.`);
|
||||
await approveCorrection(currentCorrection.id);
|
||||
logger.info(`Correction ${currentCorrection.id} approved.`);
|
||||
} else {
|
||||
await rejectCorrection(correction.id);
|
||||
logger.info(`Correction ${correction.id} rejected.`);
|
||||
await rejectCorrection(currentCorrection.id);
|
||||
logger.info(`Correction ${currentCorrection.id} rejected.`);
|
||||
}
|
||||
onProcessed(correction.id);
|
||||
} catch (err) {
|
||||
@@ -56,12 +66,71 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction, master
|
||||
// object is an instance of Error before accessing its message property.
|
||||
const errorMessage = err instanceof Error ? err.message : `An unknown error occurred while trying to ${actionToConfirm} the correction.`;
|
||||
logger.error(`Failed to ${actionToConfirm} correction ${correction.id}`, { error: errorMessage });
|
||||
setError(errorMessage);
|
||||
setError(errorMessage); // Show error on the row
|
||||
setIsProcessing(false);
|
||||
}
|
||||
setActionToConfirm(null);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedCorrection = await updateSuggestedCorrection(currentCorrection.id, editableValue);
|
||||
setCurrentCorrection(updatedCorrection); // Update local state with the saved version
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to save changes.';
|
||||
logger.error(`Failed to update correction ${currentCorrection.id}`, { error: errorMessage });
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEditableField = () => {
|
||||
switch (currentCorrection.correction_type) {
|
||||
case 'WRONG_PRICE':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={editableValue}
|
||||
onChange={(e) => setEditableValue(e.target.value)}
|
||||
className="form-input w-full text-sm dark:bg-gray-700 dark:border-gray-600"
|
||||
placeholder="Price in cents"
|
||||
/>
|
||||
);
|
||||
case 'INCORRECT_ITEM_LINK':
|
||||
return (
|
||||
<select
|
||||
value={editableValue}
|
||||
onChange={(e) => setEditableValue(e.target.value)}
|
||||
className="form-select w-full text-sm dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
{masterItems.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
case 'ITEM_IS_MISCATEGORIZED':
|
||||
return (
|
||||
<select
|
||||
value={editableValue}
|
||||
onChange={(e) => setEditableValue(e.target.value)}
|
||||
className="form-select w-full text-sm dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
{categories.map(cat => <option key={cat.id} value={cat.id}>{cat.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={editableValue}
|
||||
onChange={(e) => setEditableValue(e.target.value)}
|
||||
className="form-input w-full text-sm dark:bg-gray-700 dark:border-gray-600"
|
||||
/>);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmationModal
|
||||
@@ -87,19 +156,35 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction, master
|
||||
}
|
||||
/>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{correction.flyer_item_name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Original Price: {correction.flyer_item_price_display}</div>
|
||||
<td className="px-6 py-4 align-top">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{currentCorrection.flyer_item_name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Original Price: {currentCorrection.flyer_item_price_display}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{correction.correction_type}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-blue-600 dark:text-blue-400">{formatSuggestedValue()}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{correction.user_email || 'Unknown'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{new Date(correction.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td className="px-6 py-4 align-top text-sm text-gray-500 dark:text-gray-400">{currentCorrection.correction_type}</td>
|
||||
<td className="px-6 py-4 align-top text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
{isEditing ? renderEditableField() : formatSuggestedValue()}
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top text-sm text-gray-500 dark:text-gray-400">{currentCorrection.user_email || 'Unknown'}</td>
|
||||
<td className="px-6 py-4 align-top text-sm text-gray-500 dark:text-gray-400">{new Date(currentCorrection.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 align-top text-right text-sm font-medium">
|
||||
{isProcessing ? (
|
||||
<div className="flex justify-end items-center"><LoadingSpinner /></div>
|
||||
) : isEditing ? (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button onClick={handleSaveEdit} className="p-1.5 text-green-600 hover:bg-green-100 dark:hover:bg-green-800/50 rounded-md" title="Save"><CheckIcon className="w-5 h-5" /></button>
|
||||
<button onClick={() => { setIsEditing(false); setError(null); setEditableValue(currentCorrection.suggested_value); }} className="p-1.5 text-red-600 hover:bg-red-100 dark:hover:bg-red-800/50 rounded-md" title="Cancel"><XMarkIcon className="w-5 h-5" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
setError(null);
|
||||
setEditableValue(currentCorrection.suggested_value);
|
||||
}}
|
||||
className="p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
title="Edit"
|
||||
><PencilIcon className="w-5 h-5" /></button>
|
||||
<button onClick={() => { setActionToConfirm('approve'); setIsModalOpen(true); }} className="p-1.5 text-green-600 hover:bg-green-100 dark:hover:bg-green-800/50 rounded-md" title="Approve"><CheckIcon className="w-5 h-5" /></button>
|
||||
<button onClick={() => { setActionToConfirm('reject'); setIsModalOpen(true); }} className="p-1.5 text-red-600 hover:bg-red-100 dark:hover:bg-red-800/50 rounded-md" title="Reject"><XMarkIcon className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
14
src/components/icons/BellAlertIcon.tsx
Normal file
14
src/components/icons/BellAlertIcon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export const BellAlertIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0M3.124 7.5A8.969 8.969 0 015.292 3m13.416 0a8.969 8.969 0 012.168 4.5" />
|
||||
</svg>
|
||||
);
|
||||
14
src/components/icons/BuildingStorefrontIcon.tsx
Normal file
14
src/components/icons/BuildingStorefrontIcon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export const BuildingStorefrontIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 21v-7.5A2.25 2.25 0 0115.75 11.25h.5a2.25 2.25 0 012.25 2.25V21M3 16.5v-7.5A2.25 2.25 0 015.25 6.75h13.5A2.25 2.25 0 0121 9v7.5M3 16.5h18M3 16.5v4.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75v-4.5M16.5 4.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
14
src/components/icons/ChartBarIcon.tsx
Normal file
14
src/components/icons/ChartBarIcon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export const ChartBarIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
);
|
||||
14
src/components/icons/DocumentDuplicateIcon.tsx
Normal file
14
src/components/icons/DocumentDuplicateIcon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export const DocumentDuplicateIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
);
|
||||
14
src/components/icons/PencilIcon.tsx
Normal file
14
src/components/icons/PencilIcon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export const PencilIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
);
|
||||
14
src/components/icons/UsersIcon.tsx
Normal file
14
src/components/icons/UsersIcon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export const UsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m-7.5-2.962c.57-.063 1.14-.094 1.722-.094s1.152.031 1.722.094m-7.5 2.962l-1.622.163a2.25 2.25 0 01-2.15-2.15l.163-1.622m0 0l2.15-2.15a6.75 6.75 0 019.546 0l2.15 2.15m-9.546 0a6.75 6.75 0 00-9.546 0m9.546 0L10.5 12.562m-4.5 4.5L10.5 12.562" />
|
||||
</svg>
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { SystemCheck } from '../components/SystemCheck';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShieldExclamationIcon } from '../components/icons/ShieldExclamationIcon';
|
||||
import { ChartBarIcon } from '../components/icons/ChartBarIcon';
|
||||
|
||||
export const AdminPage: React.FC = () => {
|
||||
// The onReady prop for SystemCheck is present to allow for future UI changes,
|
||||
@@ -20,10 +21,16 @@ export const AdminPage: React.FC = () => {
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white flex items-center mb-3">
|
||||
Management
|
||||
</h3>
|
||||
<Link to="/admin/corrections" className="flex items-center p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<ShieldExclamationIcon className="w-6 h-6 mr-3 text-brand-primary" />
|
||||
<span className="font-semibold">Review Corrections</span>
|
||||
</Link>
|
||||
<div className="space-y-2">
|
||||
<Link to="/admin/corrections" className="flex items-center p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<ShieldExclamationIcon className="w-6 h-6 mr-3 text-brand-primary" />
|
||||
<span className="font-semibold">Review Corrections</span>
|
||||
</Link>
|
||||
<Link to="/admin/stats" className="flex items-center p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<ChartBarIcon className="w-6 h-6 mr-3 text-brand-primary" />
|
||||
<span className="font-semibold">View Statistics</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<SystemCheck onReady={() => { /* The system is ready */ }} />
|
||||
</div>
|
||||
|
||||
70
src/pages/AdminStatPages.tsx
Normal file
70
src/pages/AdminStatPages.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getApplicationStats, AppStats } from '../services/apiClient';
|
||||
import { logger } from '../services/logger';
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||||
import { ChartBarIcon } from '../components/icons/ChartBarIcon';
|
||||
import { UsersIcon } from '../components/icons/UsersIcon';
|
||||
import { DocumentDuplicateIcon } from '../components/icons/DocumentDuplicateIcon';
|
||||
import { BuildingStorefrontIcon } from '../components/icons/BuildingStorefrontIcon';
|
||||
import { BellAlertIcon } from '../components/icons/BellAlertIcon';
|
||||
|
||||
const StatCard: React.FC<{ title: string; value: number | string; icon: React.ReactNode }> = ({ title, value, icon }) => (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 flex items-center">
|
||||
<div className="mr-4 text-brand-primary bg-brand-primary/10 p-3 rounded-lg">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AdminStatsPage: React.FC = () => {
|
||||
const [stats, setStats] = useState<AppStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getApplicationStats();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||
logger.error('Failed to fetch application stats', { error: err });
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-lg mx-auto py-8 px-4">
|
||||
<div className="mb-8">
|
||||
<Link to="/admin" className="text-brand-primary hover:underline">← Back to Admin Dashboard</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white mt-2">Application Statistics</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">A high-level overview of key application metrics.</p>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="flex justify-center items-center h-64"><LoadingSpinner /></div>}
|
||||
{error && <div className="text-red-500 bg-red-100 dark:bg-red-900/20 p-4 rounded-lg">{error}</div>}
|
||||
|
||||
{stats && !isLoading && !error && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<StatCard title="Total Users" value={stats.userCount.toLocaleString()} icon={<UsersIcon className="w-6 h-6" />} />
|
||||
<StatCard title="Flyers Processed" value={stats.flyerCount.toLocaleString()} icon={<DocumentDuplicateIcon className="w-6 h-6" />} />
|
||||
<StatCard title="Total Flyer Items" value={stats.flyerItemCount.toLocaleString()} icon={<ChartBarIcon className="w-6 h-6" />} />
|
||||
<StatCard title="Stores Tracked" value={stats.storeCount.toLocaleString()} icon={<BuildingStorefrontIcon className="w-6 h-6" />} />
|
||||
<StatCard title="Pending Corrections" value={stats.pendingCorrectionCount.toLocaleString()} icon={<BellAlertIcon className="w-6 h-6" />} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getSuggestedCorrections, getAllMasterItems } from '../services/supabaseClient';
|
||||
import { getSuggestedCorrections, fetchMasterItems, fetchCategories } from '../services/apiClient';
|
||||
import { logger } from '../services/logger';
|
||||
import type { SuggestedCorrection, MasterGroceryItem } from '../types';
|
||||
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../types';
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||||
import { ArrowPathIcon } from '../components/icons/ArrowPathIcon';
|
||||
import { CorrectionRow } from '../components/CorrectionRow';
|
||||
@@ -11,19 +11,22 @@ export const CorrectionsPage: React.FC = () => {
|
||||
const [corrections, setCorrections] = useState<SuggestedCorrection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCorrections = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Fetch corrections and master items in parallel for efficiency
|
||||
const [correctionsData, masterItemsData] = await Promise.all([
|
||||
// Fetch all required data in parallel for efficiency
|
||||
const [correctionsData, masterItemsData, categoriesData] = await Promise.all([
|
||||
getSuggestedCorrections(),
|
||||
getAllMasterItems()
|
||||
fetchMasterItems(),
|
||||
fetchCategories()
|
||||
]);
|
||||
setCorrections(correctionsData);
|
||||
setMasterItems(masterItemsData);
|
||||
setCategories(categoriesData);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch corrections', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred while fetching corrections.';
|
||||
@@ -42,7 +45,7 @@ export const CorrectionsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto py-8 px-4">
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
<div className="mb-8">
|
||||
<Link to="/admin" className="text-brand-primary hover:underline">← Back to Admin Dashboard</Link>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
@@ -86,6 +89,7 @@ export const CorrectionsPage: React.FC = () => {
|
||||
key={correction.id}
|
||||
correction={correction}
|
||||
masterItems={masterItems}
|
||||
categories={categories}
|
||||
onProcessed={handleCorrectionProcessed} />
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Profile, UserProfile } from '../types';
|
||||
import { Profile, UserProfile, Flyer, MasterGroceryItem, ShoppingList, ShoppingListItem, FlyerItem, SuggestedCorrection, Category } from '../types';
|
||||
|
||||
interface AuthResponse {
|
||||
user: { id: string; email: string };
|
||||
@@ -150,6 +150,247 @@ export const checkDbPoolHealth = async (): Promise<{ success: boolean; message:
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all flyers from the backend.
|
||||
* @returns A promise that resolves to an array of Flyer objects.
|
||||
*/
|
||||
export const fetchFlyers = async (): Promise<Flyer[]> => {
|
||||
const response = await fetch(`${API_BASE_URL}/flyers`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch flyers.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all master grocery items from the backend.
|
||||
* @returns A promise that resolves to an array of MasterGroceryItem objects.
|
||||
*/
|
||||
export const fetchMasterItems = async (): Promise<MasterGroceryItem[]> => {
|
||||
const response = await fetch(`${API_BASE_URL}/master-items`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch master items.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all categories from the backend.
|
||||
* @returns A promise that resolves to an array of Category objects.
|
||||
*/
|
||||
export const fetchCategories = async (): Promise<Category[]> => {
|
||||
const response = await fetch(`${API_BASE_URL}/categories`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch categories.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
|
||||
// --- Flyer Processing API Function ---
|
||||
|
||||
/**
|
||||
* Uploads a flyer image and its extracted data to the backend for processing and storage.
|
||||
* @param flyerImage The image file of the flyer's first page.
|
||||
* @param checksum The SHA-256 checksum of the original file.
|
||||
* @param originalFileName The original name of the uploaded file.
|
||||
* @param extractedData The data extracted from the flyer by the AI.
|
||||
* @returns A promise that resolves with the backend's response.
|
||||
*/
|
||||
export const processFlyerFile = async (
|
||||
flyerImage: File,
|
||||
checksum: string,
|
||||
originalFileName: string,
|
||||
extractedData: { store_name: string; valid_from: string; valid_to: string; items: Omit<FlyerItem, 'id' | 'flyer_id' | 'created_at'>[]; store_address: string | null }
|
||||
): Promise<{ message: string; flyer: Flyer }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('flyerImage', flyerImage);
|
||||
|
||||
// We send the structured data as a JSON string in a separate form field.
|
||||
const dataPayload = { checksum, originalFileName, extractedData };
|
||||
formData.append('data', JSON.stringify(dataPayload));
|
||||
|
||||
// We use the standard `fetch` here because `apiFetch` is for JSON APIs and token refresh.
|
||||
// File uploads with FormData have different header requirements.
|
||||
const response = await fetch(`${API_BASE_URL}/flyers/process`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// DO NOT set 'Content-Type' header manually; the browser does it correctly for FormData.
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// --- Flyer Item API Functions ---
|
||||
|
||||
export const fetchFlyerItems = async (flyerId: number): Promise<FlyerItem[]> => {
|
||||
const response = await fetch(`${API_BASE_URL}/flyers/${flyerId}/items`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch flyer items.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const fetchFlyerItemsForFlyers = async (flyerIds: number[]): Promise<FlyerItem[]> => {
|
||||
if (flyerIds.length === 0) return [];
|
||||
const response = await fetch(`${API_BASE_URL}/flyer-items/batch-fetch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ flyerIds }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch items for multiple flyers.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise<number> => {
|
||||
if (flyerIds.length === 0) return 0;
|
||||
const response = await fetch(`${API_BASE_URL}/flyer-items/batch-count`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ flyerIds }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to count items for multiple flyers.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
const { count } = await response.json();
|
||||
return count;
|
||||
};
|
||||
|
||||
// --- Store API Functions ---
|
||||
|
||||
/**
|
||||
* Uploads a new logo for a store.
|
||||
* @param storeId The ID of the store to update.
|
||||
* @param logoImage The logo image file.
|
||||
* @returns A promise that resolves with the new logo URL.
|
||||
*/
|
||||
export const uploadLogoAndUpdateStore = async (storeId: number, logoImage: File): Promise<{ logoUrl: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('logoImage', logoImage);
|
||||
|
||||
// Use apiFetch to ensure the user is authenticated to perform this action.
|
||||
const response = await apiFetch(`${API_BASE_URL}/stores/${storeId}/logo`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// Do not set Content-Type for FormData, browser handles it.
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
|
||||
// --- Watched Items API Functions ---
|
||||
|
||||
export const fetchWatchedItems = async (): Promise<MasterGroceryItem[]> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/watched-items`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch watched items.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const addWatchedItem = async (itemName: string, category: string): Promise<MasterGroceryItem> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/watched-items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemName, category }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to add watched item.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const removeWatchedItem = async (masterItemId: number): Promise<void> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/watched-items/${masterItemId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to remove watched item.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Shopping List API Functions ---
|
||||
|
||||
export const fetchShoppingLists = async (): Promise<ShoppingList[]> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/shopping-lists`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch shopping lists.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createShoppingList = async (name: string): Promise<ShoppingList> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/shopping-lists`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to create shopping list.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteShoppingList = async (listId: number): Promise<void> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/${listId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to delete shopping list.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const addShoppingListItem = async (listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/${listId}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(item),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to add item to list.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateShoppingListItem = async (itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/items/${itemId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to update list item.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const removeShoppingListItem = async (itemId: number): Promise<void> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/items/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to remove list item.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Fetches the full profile for the currently authenticated user.
|
||||
@@ -197,6 +438,91 @@ export async function loginUser(email: string, password: string): Promise<AuthRe
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface AppStats {
|
||||
flyerCount: number;
|
||||
userCount: number;
|
||||
flyerItemCount: number;
|
||||
storeCount: number;
|
||||
pendingCorrectionCount: number;
|
||||
}
|
||||
|
||||
export interface DailyStat {
|
||||
date: string;
|
||||
new_users: number;
|
||||
new_flyers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches daily user registration and flyer upload stats for the last 30 days.
|
||||
* @returns A promise that resolves to an array of daily stat objects.
|
||||
*/
|
||||
export const getDailyStats = async (): Promise<DailyStat[]> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/admin/stats/daily`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch daily statistics.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
/**
|
||||
* Fetches application-wide statistics. Requires admin privileges.
|
||||
* @returns A promise that resolves to an object containing app stats.
|
||||
*/
|
||||
export const getApplicationStats = async (): Promise<AppStats> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/admin/stats`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch application statistics.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// --- Admin Correction API Functions ---
|
||||
|
||||
/**
|
||||
* Fetches all pending suggested corrections. Requires admin privileges.
|
||||
* @returns A promise that resolves to an array of SuggestedCorrection objects.
|
||||
*/
|
||||
export const getSuggestedCorrections = async (): Promise<SuggestedCorrection[]> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/admin/corrections`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch suggested corrections.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Approves a suggested correction. Requires admin privileges.
|
||||
* @param correctionId The ID of the correction to approve.
|
||||
* @returns A promise that resolves with the success message.
|
||||
*/
|
||||
export const approveCorrection = async (correctionId: number): Promise<{ message: string }> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/admin/corrections/${correctionId}/approve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to approve correction.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Rejects a suggested correction. Requires admin privileges.
|
||||
* @param correctionId The ID of the correction to reject.
|
||||
*/
|
||||
export const rejectCorrection = async (correctionId: number): Promise<{ message: string }> => {
|
||||
const response = await apiFetch(`${API_BASE_URL}/admin/corrections/${correctionId}/reject`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to reject correction.' }));
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export async function registerUser(email: string, password: string): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from './logger';
|
||||
import { Profile } from '../types'; // Assuming your Profile type is in `types.ts`
|
||||
import { Profile, Flyer, MasterGroceryItem, ShoppingList, ShoppingListItem, FlyerItem, SuggestedCorrection } from '../types'; // Assuming your Profile type is in `types.ts`
|
||||
|
||||
// Configure your PostgreSQL connection pool
|
||||
// IMPORTANT: For production, use environment variables for these credentials.
|
||||
@@ -226,4 +226,688 @@ export async function findUserByRefreshToken(refreshToken: string): Promise<{ id
|
||||
logger.error('Database error in findUserByRefreshToken:', { error });
|
||||
return undefined; // Return undefined on error to prevent token leakage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all flyers from the database, joining with store information.
|
||||
* @returns A promise that resolves to an array of Flyer objects.
|
||||
*/
|
||||
export async function getFlyers(): Promise<Flyer[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
f.id,
|
||||
f.created_at,
|
||||
f.file_name,
|
||||
f.image_url,
|
||||
f.checksum,
|
||||
f.store_id,
|
||||
f.valid_from,
|
||||
f.valid_to,
|
||||
f.store_address,
|
||||
json_build_object(
|
||||
'id', s.id,
|
||||
'name', s.name,
|
||||
'logo_url', s.logo_url
|
||||
) as store
|
||||
FROM public.flyers f
|
||||
LEFT JOIN public.stores s ON f.store_id = s.id
|
||||
ORDER BY f.valid_to DESC, s.name ASC;
|
||||
`;
|
||||
const res = await pool.query<Flyer>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getFlyers:', { error });
|
||||
throw new Error('Failed to retrieve flyers from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all master grocery items from the database, joining with category information.
|
||||
* @returns A promise that resolves to an array of MasterGroceryItem objects.
|
||||
*/
|
||||
export async function getAllMasterItems(): Promise<MasterGroceryItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
m.id,
|
||||
m.created_at,
|
||||
m.name,
|
||||
m.category_id,
|
||||
c.name as category_name
|
||||
FROM public.master_grocery_items m
|
||||
LEFT JOIN public.categories c ON m.category_id = c.id
|
||||
ORDER BY m.name ASC;
|
||||
`;
|
||||
const res = await pool.query<MasterGroceryItem>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getAllMasterItems:', { error });
|
||||
throw new Error('Failed to retrieve master items from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all categories from the database.
|
||||
* @returns A promise that resolves to an array of Category objects.
|
||||
*/
|
||||
export async function getAllCategories(): Promise<{id: number, name: string}[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT id, name FROM public.categories ORDER BY name ASC;
|
||||
`;
|
||||
const res = await pool.query<{id: number, name: string}>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getAllCategories:', { error });
|
||||
throw new Error('Failed to retrieve categories from database.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watched Items Functions ---
|
||||
|
||||
/**
|
||||
* Retrieves all watched master items for a specific user.
|
||||
* @param userId The UUID of the user.
|
||||
* @returns A promise that resolves to an array of MasterGroceryItem objects.
|
||||
*/
|
||||
export async function getWatchedItems(userId: string): Promise<MasterGroceryItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT mgi.*
|
||||
FROM public.master_grocery_items mgi
|
||||
JOIN public.user_watched_items uwi ON mgi.id = uwi.master_item_id
|
||||
WHERE uwi.user_id = $1
|
||||
ORDER BY mgi.name ASC;
|
||||
`;
|
||||
const res = await pool.query<MasterGroceryItem>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getWatchedItems:', { error, userId });
|
||||
throw new Error('Failed to retrieve watched items.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an item to a user's watchlist. If the master item doesn't exist, it creates it.
|
||||
* @param userId The UUID of the user.
|
||||
* @param itemName The name of the item to watch.
|
||||
* @param categoryName The category of the item.
|
||||
* @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist.
|
||||
*/
|
||||
export async function addWatchedItem(userId: string, itemName: string, categoryName: string): Promise<MasterGroceryItem> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Find category ID
|
||||
const categoryRes = await client.query<{ id: number }>('SELECT id FROM public.categories WHERE name = $1', [categoryName]);
|
||||
const categoryId = categoryRes.rows[0]?.id;
|
||||
if (!categoryId) {
|
||||
throw new Error(`Category '${categoryName}' not found.`);
|
||||
}
|
||||
|
||||
// Find or create master item
|
||||
let masterItem: MasterGroceryItem;
|
||||
const masterItemRes = await client.query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items WHERE name = $1', [itemName]);
|
||||
if (masterItemRes.rows.length > 0) {
|
||||
masterItem = masterItemRes.rows[0];
|
||||
} else {
|
||||
const newMasterItemRes = await client.query<MasterGroceryItem>(
|
||||
'INSERT INTO public.master_grocery_items (name, category_id) VALUES ($1, $2) RETURNING *',
|
||||
[itemName, categoryId]
|
||||
);
|
||||
masterItem = newMasterItemRes.rows[0];
|
||||
}
|
||||
|
||||
// Add to user's watchlist, ignoring if it's already there.
|
||||
await client.query(
|
||||
'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2) ON CONFLICT (user_id, master_item_id) DO NOTHING',
|
||||
[userId, masterItem.id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return masterItem;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database transaction error in addWatchedItem:', { error });
|
||||
throw new Error('Failed to add item to watchlist.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from a user's watchlist.
|
||||
* @param userId The UUID of the user.
|
||||
* @param masterItemId The ID of the master item to remove.
|
||||
*/
|
||||
export async function removeWatchedItem(userId: string, masterItemId: number): Promise<void> {
|
||||
try {
|
||||
await pool.query('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', [userId, masterItemId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in removeWatchedItem:', { error });
|
||||
throw new Error('Failed to remove item from watchlist.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shopping List Functions ---
|
||||
|
||||
/**
|
||||
* Retrieves all shopping lists and their items for a user.
|
||||
* @param userId The UUID of the user.
|
||||
* @returns A promise that resolves to an array of ShoppingList objects.
|
||||
*/
|
||||
export async function getShoppingLists(userId: string): Promise<ShoppingList[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
sl.id, sl.name, sl.created_at,
|
||||
COALESCE(
|
||||
(SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', sli.id,
|
||||
'shopping_list_id', sli.shopping_list_id,
|
||||
'master_item_id', sli.master_item_id,
|
||||
'custom_item_name', sli.custom_item_name,
|
||||
'quantity', sli.quantity,
|
||||
'is_purchased', sli.is_purchased,
|
||||
'added_at', sli.added_at,
|
||||
'master_item', json_build_object('name', mgi.name)
|
||||
) ORDER BY sli.added_at ASC
|
||||
) FROM public.shopping_list_items sli
|
||||
LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.id
|
||||
WHERE sli.shopping_list_id = sl.id),
|
||||
'[]'::json
|
||||
) as items
|
||||
FROM public.shopping_lists sl
|
||||
WHERE sl.user_id = $1
|
||||
ORDER BY sl.created_at ASC;
|
||||
`;
|
||||
const res = await pool.query<ShoppingList>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getShoppingLists:', { error, userId });
|
||||
throw new Error('Failed to retrieve shopping lists.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createShoppingList(userId: string, name: string): Promise<ShoppingList> {
|
||||
try {
|
||||
const res = await pool.query<ShoppingList>(
|
||||
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING id, user_id, name, created_at',
|
||||
[userId, name]
|
||||
);
|
||||
// Return a complete ShoppingList object with an empty items array
|
||||
return { ...res.rows[0], items: [] };
|
||||
} catch (error) {
|
||||
logger.error('Database error in createShoppingList:', { error });
|
||||
throw new Error('Failed to create shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteShoppingList(listId: number, userId: string): Promise<void> {
|
||||
try {
|
||||
// The user_id check ensures a user can only delete their own list.
|
||||
await pool.query('DELETE FROM public.shopping_lists WHERE id = $1 AND user_id = $2', [listId, userId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in deleteShoppingList:', { error });
|
||||
throw new Error('Failed to delete shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> {
|
||||
try {
|
||||
const res = await pool.query<ShoppingListItem>(
|
||||
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *',
|
||||
[listId, item.masterItemId, item.customItemName]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in addShoppingListItem:', { error });
|
||||
throw new Error('Failed to add item to shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateShoppingListItem(itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> {
|
||||
try {
|
||||
const res = await pool.query<ShoppingListItem>(
|
||||
'UPDATE public.shopping_list_items SET quantity = $1, is_purchased = $2 WHERE id = $3 RETURNING *',
|
||||
[updates.quantity, updates.is_purchased, itemId]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateShoppingListItem:', { error });
|
||||
throw new Error('Failed to update shopping list item.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeShoppingListItem(itemId: number): Promise<void> {
|
||||
try {
|
||||
await pool.query('DELETE FROM public.shopping_list_items WHERE id = $1', [itemId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in removeShoppingListItem:', { error });
|
||||
throw new Error('Failed to remove item from shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flyer Processing Functions ---
|
||||
|
||||
/**
|
||||
* Finds a flyer by its checksum to prevent duplicate processing.
|
||||
* @param checksum The SHA-256 checksum of the flyer file.
|
||||
* @returns A promise that resolves to the Flyer object if found, otherwise undefined.
|
||||
*/
|
||||
export async function findFlyerByChecksum(checksum: string): Promise<Flyer | undefined> {
|
||||
try {
|
||||
const res = await pool.query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [checksum]);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findFlyerByChecksum:', { error });
|
||||
throw new Error('Failed to check for existing flyer.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new flyer and all its associated items in a single database transaction.
|
||||
* @param flyerData The metadata for the flyer.
|
||||
* @param items The array of flyer items extracted from the flyer.
|
||||
* @returns A promise that resolves to the newly created Flyer object.
|
||||
*/
|
||||
export async function createFlyerAndItems(
|
||||
flyerData: Omit<Flyer, 'id' | 'created_at' | 'store'> & { store_name: string },
|
||||
items: Omit<FlyerItem, 'id' | 'flyer_id' | 'created_at'>[]
|
||||
): Promise<Flyer> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Find or create the store
|
||||
let storeId: number;
|
||||
const storeRes = await client.query<{ id: number }>('SELECT id FROM public.stores WHERE name = $1', [flyerData.store_name]);
|
||||
if (storeRes.rows.length > 0) {
|
||||
storeId = storeRes.rows[0].id;
|
||||
} else {
|
||||
const newStoreRes = await client.query<{ id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING id', [flyerData.store_name]);
|
||||
storeId = newStoreRes.rows[0].id;
|
||||
}
|
||||
|
||||
// Create the flyer record
|
||||
const flyerQuery = `
|
||||
INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to, store_address)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *;
|
||||
`;
|
||||
const flyerValues = [flyerData.file_name, flyerData.image_url, flyerData.checksum, storeId, flyerData.valid_from, flyerData.valid_to, flyerData.store_address];
|
||||
const newFlyerRes = await client.query<Flyer>(flyerQuery, flyerValues);
|
||||
const newFlyer = newFlyerRes.rows[0];
|
||||
|
||||
// Prepare and insert all flyer items
|
||||
if (items.length > 0) {
|
||||
// This approach builds a single INSERT statement with multiple VALUES clauses,
|
||||
// which is highly efficient for bulk inserts.
|
||||
const itemInsertQuery = `
|
||||
INSERT INTO public.flyer_items (flyer_id, item, price_display, price_in_cents, quantity, master_item_id, category_name, unit_price)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`;
|
||||
|
||||
// We can execute multiple queries within the transaction.
|
||||
// Looping and executing one query per item is safe and clear.
|
||||
// For very high performance needs, a more complex single-query builder could be used,
|
||||
// but this is a robust and secure starting point.
|
||||
for (const item of items) {
|
||||
const itemValues = [
|
||||
newFlyer.id,
|
||||
item.item,
|
||||
item.price_display,
|
||||
item.price_in_cents,
|
||||
item.quantity,
|
||||
item.master_item_id,
|
||||
item.category_name,
|
||||
item.unit_price ? JSON.stringify(item.unit_price) : null // Ensure JSONB is correctly stringified
|
||||
];
|
||||
await client.query(itemInsertQuery, itemValues);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return newFlyer;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database transaction error in createFlyerAndItems:', { error });
|
||||
throw new Error('Failed to save flyer and its items.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all items for a specific flyer.
|
||||
* @param flyerId The ID of the flyer.
|
||||
* @returns A promise that resolves to an array of FlyerItem objects.
|
||||
*/
|
||||
export async function getFlyerItems(flyerId: number): Promise<FlyerItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT * FROM public.flyer_items
|
||||
WHERE flyer_id = $1
|
||||
ORDER BY id ASC;
|
||||
`;
|
||||
const res = await pool.query<FlyerItem>(query, [flyerId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getFlyerItems:', { error, flyerId });
|
||||
throw new Error('Failed to retrieve flyer items.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all flyer items for a given list of flyer IDs.
|
||||
* @param flyerIds An array of flyer IDs.
|
||||
* @returns A promise that resolves to an array of FlyerItem objects.
|
||||
*/
|
||||
export async function getFlyerItemsForFlyers(flyerIds: number[]): Promise<FlyerItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT * FROM public.flyer_items
|
||||
WHERE flyer_id = ANY($1::bigint[]);
|
||||
`;
|
||||
const res = await pool.query<FlyerItem>(query, [flyerIds]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getFlyerItemsForFlyers:', { error });
|
||||
throw new Error('Failed to retrieve items for multiple flyers.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the total number of flyer items for a given list of flyer IDs.
|
||||
* @param flyerIds An array of flyer IDs.
|
||||
* @returns A promise that resolves to the total count of items.
|
||||
*/
|
||||
export async function countFlyerItemsForFlyers(flyerIds: number[]): Promise<number> {
|
||||
try {
|
||||
const query = `SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])`;
|
||||
const res = await pool.query<{ count: string }>(query, [flyerIds]);
|
||||
return parseInt(res.rows[0].count, 10);
|
||||
} catch (error) {
|
||||
logger.error('Database error in countFlyerItemsForFlyers:', { error });
|
||||
throw new Error('Failed to count items for multiple flyers.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the logo URL for a specific store.
|
||||
* @param storeId The ID of the store to update.
|
||||
* @param logoUrl The new URL for the store's logo.
|
||||
*/
|
||||
export async function updateStoreLogo(storeId: number, logoUrl: string): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
'UPDATE public.stores SET logo_url = $1 WHERE id = $2',
|
||||
[logoUrl, storeId]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateStoreLogo:', { error, storeId });
|
||||
throw new Error('Failed to update store logo in database.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Correction Handler Logic ---
|
||||
|
||||
/**
|
||||
* A map of functions to handle applying different types of corrections.
|
||||
* This makes it easy to add new correction types without modifying the main approveCorrection function.
|
||||
* Each handler receives the database client, the flyer item ID, and the suggested value.
|
||||
*/
|
||||
const correctionHandlers: { [key: string]: (client: any, flyerItemId: number, suggestedValue: string) => Promise<void> } = {
|
||||
'WRONG_PRICE': async (client, flyerItemId, suggestedValue) => {
|
||||
const priceInCents = parseInt(suggestedValue, 10);
|
||||
if (isNaN(priceInCents)) {
|
||||
throw new Error(`Invalid suggested value for WRONG_PRICE: ${suggestedValue}`);
|
||||
}
|
||||
const priceDisplay = `$${(priceInCents / 100).toFixed(2)}`;
|
||||
await client.query(
|
||||
'UPDATE public.flyer_items SET price_in_cents = $1, price_display = $2 WHERE id = $3',
|
||||
[priceInCents, priceDisplay, flyerItemId]
|
||||
);
|
||||
},
|
||||
'INCORRECT_ITEM_LINK': async (client, flyerItemId, suggestedValue) => {
|
||||
const masterItemId = parseInt(suggestedValue, 10);
|
||||
if (isNaN(masterItemId)) {
|
||||
throw new Error(`Invalid suggested value for INCORRECT_ITEM_LINK: ${suggestedValue}`);
|
||||
}
|
||||
await client.query(
|
||||
'UPDATE public.flyer_items SET master_item_id = $1 WHERE id = $2',
|
||||
[masterItemId, flyerItemId]
|
||||
);
|
||||
},
|
||||
'ITEM_IS_MISCATEGORIZED': async (client, flyerItemId, suggestedValue) => {
|
||||
const categoryId = parseInt(suggestedValue, 10);
|
||||
if (isNaN(categoryId)) {
|
||||
throw new Error(`Invalid suggested value for ITEM_IS_MISCATEGORIZED: ${suggestedValue}`);
|
||||
}
|
||||
await client.query(
|
||||
'UPDATE public.flyer_items SET category_id = $1 WHERE id = $2',
|
||||
[categoryId, flyerItemId]
|
||||
);
|
||||
},
|
||||
// To add a new correction type, simply add a new handler function here.
|
||||
};
|
||||
|
||||
|
||||
// --- Admin Correction Functions ---
|
||||
|
||||
/**
|
||||
* Retrieves all pending suggested corrections from the database.
|
||||
* Joins with users and flyer_items to provide context for the admin.
|
||||
* @returns A promise that resolves to an array of SuggestedCorrection objects.
|
||||
*/
|
||||
export async function getSuggestedCorrections(): Promise<SuggestedCorrection[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
sc.id,
|
||||
sc.flyer_item_id,
|
||||
sc.user_id,
|
||||
sc.correction_type,
|
||||
sc.suggested_value,
|
||||
sc.status,
|
||||
sc.created_at,
|
||||
fi.item as flyer_item_name,
|
||||
fi.price_display as flyer_item_price_display,
|
||||
u.email as user_email
|
||||
FROM public.suggested_corrections sc
|
||||
JOIN public.flyer_items fi ON sc.flyer_item_id = fi.id
|
||||
LEFT JOIN public.users u ON sc.user_id = u.id
|
||||
WHERE sc.status = 'pending'
|
||||
ORDER BY sc.created_at ASC;
|
||||
`;
|
||||
const res = await pool.query<SuggestedCorrection>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getSuggestedCorrections:', { error });
|
||||
throw new Error('Failed to retrieve suggested corrections.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves a correction and applies the change to the corresponding flyer item.
|
||||
* This function runs as a transaction to ensure data integrity.
|
||||
* @param correctionId The ID of the correction to approve.
|
||||
*/
|
||||
export async function approveCorrection(correctionId: number): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 1. Get the correction details
|
||||
const correctionRes = await client.query<SuggestedCorrection>(
|
||||
'SELECT * FROM public.suggested_corrections WHERE id = $1 FOR UPDATE',
|
||||
[correctionId]
|
||||
);
|
||||
const correction = correctionRes.rows[0];
|
||||
|
||||
if (!correction) {
|
||||
throw new Error(`Correction with ID ${correctionId} not found.`);
|
||||
}
|
||||
if (correction.status !== 'pending') {
|
||||
throw new Error(`Correction with ID ${correctionId} is not pending and cannot be processed.`);
|
||||
}
|
||||
|
||||
// 2. Find and execute the handler for the correction type
|
||||
const handler = correctionHandlers[correction.correction_type];
|
||||
if (!handler) {
|
||||
throw new Error(`No handler found for correction type: ${correction.correction_type}`);
|
||||
}
|
||||
|
||||
// Execute the specific handler for this correction type
|
||||
await handler(client, correction.flyer_item_id, correction.suggested_value);
|
||||
logger.info(`Applied correction handler for type: ${correction.correction_type}`);
|
||||
|
||||
// 3. Update the correction status to 'approved'
|
||||
await client.query(
|
||||
"UPDATE public.suggested_corrections SET status = 'approved' WHERE id = $1",
|
||||
[correctionId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
logger.info(`Successfully approved and applied correction ID: ${correctionId}`);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database transaction error in approveCorrection:', { error, correctionId });
|
||||
throw new Error('Failed to approve correction.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a correction by updating its status.
|
||||
* @param correctionId The ID of the correction to reject.
|
||||
*/
|
||||
export async function rejectCorrection(correctionId: number): Promise<void> {
|
||||
try {
|
||||
const res = await pool.query(
|
||||
"UPDATE public.suggested_corrections SET status = 'rejected' WHERE id = $1 AND status = 'pending' RETURNING id",
|
||||
[correctionId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
// This could happen if the correction was already processed or doesn't exist.
|
||||
logger.warn(`Attempted to reject correction ID ${correctionId}, but it was not found or not in 'pending' state.`);
|
||||
// We don't throw an error here, as the end state (not pending) is achieved.
|
||||
} else {
|
||||
logger.info(`Successfully rejected correction ID: ${correctionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Database error in rejectCorrection:', { error, correctionId });
|
||||
throw new Error('Failed to reject correction.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the suggested value of a pending correction.
|
||||
* @param correctionId The ID of the correction to update.
|
||||
* @param newSuggestedValue The new value to set for the suggestion.
|
||||
* @returns A promise that resolves to the updated SuggestedCorrection object.
|
||||
*/
|
||||
export async function updateSuggestedCorrection(correctionId: number, newSuggestedValue: string): Promise<SuggestedCorrection> {
|
||||
try {
|
||||
const res = await pool.query<SuggestedCorrection>(
|
||||
"UPDATE public.suggested_corrections SET suggested_value = $1 WHERE id = $2 AND status = 'pending' RETURNING *",
|
||||
[newSuggestedValue, correctionId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Correction with ID ${correctionId} not found or is not in 'pending' state.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateSuggestedCorrection:', { error, correctionId });
|
||||
throw new Error('Failed to update suggested correction.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves application-wide statistics for the admin dashboard.
|
||||
* @returns A promise that resolves to an object containing various application stats.
|
||||
*/
|
||||
export async function getApplicationStats(): Promise<{
|
||||
flyerCount: number;
|
||||
userCount: number;
|
||||
flyerItemCount: number;
|
||||
storeCount: number;
|
||||
pendingCorrectionCount: number;
|
||||
}> {
|
||||
try {
|
||||
// Run count queries in parallel for better performance
|
||||
const flyerCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.flyers');
|
||||
const userCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.users');
|
||||
const flyerItemCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items');
|
||||
const storeCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.stores');
|
||||
const pendingCorrectionCountQuery = pool.query<{ count: string }>("SELECT COUNT(*) FROM public.suggested_corrections WHERE status = 'pending'");
|
||||
|
||||
const [
|
||||
flyerCountRes,
|
||||
userCountRes,
|
||||
flyerItemCountRes,
|
||||
storeCountRes,
|
||||
pendingCorrectionCountRes
|
||||
] = await Promise.all([
|
||||
flyerCountQuery, userCountQuery, flyerItemCountQuery, storeCountQuery, pendingCorrectionCountQuery
|
||||
]);
|
||||
|
||||
return {
|
||||
flyerCount: parseInt(flyerCountRes.rows[0].count, 10),
|
||||
userCount: parseInt(userCountRes.rows[0].count, 10),
|
||||
flyerItemCount: parseInt(flyerItemCountRes.rows[0].count, 10),
|
||||
storeCount: parseInt(storeCountRes.rows[0].count, 10),
|
||||
pendingCorrectionCount: parseInt(pendingCorrectionCountRes.rows[0].count, 10),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Database error in getApplicationStats:', { error });
|
||||
throw new Error('Failed to retrieve application statistics.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves daily statistics for user registrations and flyer uploads for the last 30 days.
|
||||
* @returns A promise that resolves to an array of daily stats.
|
||||
*/
|
||||
export async function getDailyStatsForLast30Days(): Promise<{ date: string; new_users: number; new_flyers: number; }[]> {
|
||||
try {
|
||||
const query = `
|
||||
WITH date_series AS (
|
||||
SELECT generate_series(
|
||||
(CURRENT_DATE - interval '29 days'),
|
||||
CURRENT_DATE,
|
||||
'1 day'::interval
|
||||
)::date AS day
|
||||
),
|
||||
daily_users AS (
|
||||
SELECT created_at::date AS day, COUNT(*) AS user_count
|
||||
FROM public.users
|
||||
WHERE created_at >= (CURRENT_DATE - interval '29 days')
|
||||
GROUP BY 1
|
||||
),
|
||||
daily_flyers AS (
|
||||
SELECT created_at::date AS day, COUNT(*) AS flyer_count
|
||||
FROM public.flyers
|
||||
WHERE created_at >= (CURRENT_DATE - interval '29 days')
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
to_char(ds.day, 'YYYY-MM-DD') as date,
|
||||
COALESCE(du.user_count, 0)::int AS new_users,
|
||||
COALESCE(df.flyer_count, 0)::int AS new_flyers
|
||||
FROM date_series ds
|
||||
LEFT JOIN daily_users du ON ds.day = du.day
|
||||
LEFT JOIN daily_flyers df ON ds.day = df.day
|
||||
ORDER BY ds.day ASC;
|
||||
`;
|
||||
const res = await pool.query(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getDailyStatsForLast30Days:', { error });
|
||||
throw new Error('Failed to retrieve daily statistics.');
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,11 @@ export interface MasterGroceryItem {
|
||||
category_name?: string | null;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user