Compare commits

...

34 Commits

Author SHA1 Message Date
jason
c0198df6d9 pretty it up 2026-03-13 16:16:59 -05:00
jason
f1a3a31a94 fixes 2026-03-13 16:00:33 -05:00
e08a2375ae Merge pull request 'admin board' (#17) from claude/suspicious-wilson into master
Reviewed-on: #17
2026-03-13 11:44:04 -05:00
jason
707f632d34 admin board 2026-03-13 11:39:46 -05:00
6813602b6c Merge pull request 'debounce' (#16) from claude/suspicious-wilson into master
Reviewed-on: #16
2026-03-13 11:36:09 -05:00
jason
65a4f79131 debounce 2026-03-13 11:35:28 -05:00
9046370b64 Merge pull request 'timezone' (#15) from claude/suspicious-wilson into master
Reviewed-on: #15
2026-03-13 11:18:20 -05:00
jason
0e2dc27779 timezone 2026-03-13 11:18:11 -05:00
9e735b00f2 Merge pull request 'multi-file update' (#14) from claude/suspicious-wilson into master
Reviewed-on: #14
2026-03-13 11:15:35 -05:00
jason
b2df27cfc5 multi-file update 2026-03-13 11:13:45 -05:00
jason
5e3ca19c83 stuff 2026-03-13 09:29:39 -05:00
b81a568592 Merge pull request 'fix: resolve "Unknown User" in admin panel' (#13) from claude/quirky-golick into master
Reviewed-on: #13
2026-03-13 07:30:43 -05:00
04ebc91e9f fix: include user relation in admin reports query to resolve Unknown User display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:29:55 -05:00
8eaf239c5d Update README.md 2026-03-13 07:21:08 -05:00
2077ab3275 Merge pull request 'fix: read Google access_token from Account table, not getToken()' (#12) from claude/reverent-proskuriakova into master
Reviewed-on: #12
2026-03-13 00:57:20 -05:00
19b1f26254 fix: read Google access_token from Account table, not getToken()
With strategy:"database" there is no JWT cookie, so getToken() always
returns null. The Google access_token is stored in the Account table by
the PrismaAdapter. Query it directly via prisma.account.findFirst()
instead of the JWT helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:56:33 -05:00
bc9a13bfe4 Merge pull request 'fix: copy prisma.config.ts into runner so db push can find datasource URL' (#11) from claude/reverent-proskuriakova into master
Reviewed-on: #11
2026-03-13 00:45:12 -05:00
cfeee5dc2a fix: copy prisma.config.ts into runner so db push can find datasource URL
Without prisma.config.ts in the runner stage, prisma db push has no
datasource URL (schema.prisma no longer carries url in Prisma 7) and
silently skips creating the database. Also add set -e to the entrypoint
so any db push failure is visible in logs and stops the container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:44:41 -05:00
a091420573 Merge pull request 'fix: create /app/data after COPY steps to prevent permission clobber' (#10) from claude/reverent-proskuriakova into master
Reviewed-on: #10
2026-03-13 00:39:26 -05:00
b1fa70eba4 fix: create /app/data after COPY steps to prevent permission clobber
The mkdir was running before the standalone COPY, which could overwrite
/app/data contents or permissions. Move it to after all COPY statements
and use chmod 700 so only nextjs owns and can write the SQLite data dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:38:50 -05:00
ca414bb903 Merge pull request 'fix: use npm install in Docker so Alpine musl native bindings resolve' (#9) from claude/reverent-proskuriakova into master
Reviewed-on: #9
2026-03-13 00:33:05 -05:00
716b37c6f2 fix: use npm install in Docker so Alpine musl native bindings resolve
npm ci with a Windows-generated lockfile skips @libsql/linux-x64-musl
(optional native dep). Switching to npm install lets npm resolve the
correct platform-specific binary for the Alpine container. Also copy
node_modules into the runner stage so prisma db push and the libsql
native module are available at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:32:24 -05:00
8c7142ca78 Merge pull request 'fix: pass config object directly to PrismaLibSql, drop @libsql/client' (#8) from claude/reverent-proskuriakova into master
Reviewed-on: #8
2026-03-13 00:28:23 -05:00
8b18566761 fix: pass config object directly to PrismaLibSql, drop @libsql/client
PrismaLibSql constructor takes a Config object (with url), not a
pre-created Client instance. Remove the unnecessary createClient call
and the @libsql/client direct dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:28:06 -05:00
1c12b9a70d Merge pull request 'fix: remove apk upgrade from Dockerfile base stage' (#7) from claude/reverent-proskuriakova into master
Reviewed-on: #7
2026-03-13 00:25:45 -05:00
ea5b4a955d fix: remove apk upgrade from Dockerfile base stage
apk upgrade hangs on slow/unreliable Alpine mirrors and is not needed
for a build. node:20-alpine is sufficiently up to date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:24:52 -05:00
d2d1eb17b5 Merge pull request 'fix: exclude prisma.config.ts from TS compilation, use datasource.url' (#6) from claude/reverent-proskuriakova into master
Reviewed-on: #6
2026-03-13 00:17:52 -05:00
3e536a0a0e fix: exclude prisma.config.ts from TS compilation, use datasource.url
prisma.config.ts is a Prisma CLI config file, not part of the Next.js
app — exclude it from tsconfig to prevent type errors. Also revert the
migrate.adapter block (not a valid PrismaConfig key in 7.5) back to
datasource.url which is the correct CLI config for db push.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:17:17 -05:00
1b0982d523 Merge pull request 'fix: correct PrismaLibSQL -> PrismaLibSql export name' (#5) from claude/reverent-proskuriakova into master
Reviewed-on: #5
2026-03-13 00:14:35 -05:00
e7560bedff fix: correct PrismaLibSQL -> PrismaLibSql export name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:14:12 -05:00
109a30699a Merge pull request 'fix: exclude libsql packages from webpack bundling' (#4) from claude/reverent-proskuriakova into master
Reviewed-on: #4
2026-03-13 00:01:42 -05:00
aeee0fb598 fix: exclude libsql packages from webpack bundling
@libsql/client and libsql contain native bindings and non-JS assets
(README.md, LICENSE) that webpack cannot parse. Mark them as server
external packages so Next.js requires them at runtime instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:01:06 -05:00
e51311eb0c Merge pull request 'chore: update package-lock.json for libsql adapter dependencies' (#3) from claude/reverent-proskuriakova into master
Reviewed-on: #3
2026-03-12 23:55:44 -05:00
a692b99d31 chore: update package-lock.json for libsql adapter dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:53:33 -05:00
22 changed files with 785 additions and 86 deletions

View File

@@ -0,0 +1,9 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
url: env("DATABASE_URL") ?? "file:./dev.db",
},
});

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx prisma migrate dev --name add-drive-file-id)",
"Bash(npx prisma db push)"
]
}
}

Submodule .claude/worktrees/quirky-golick added at 04ebc91e9f

Submodule .claude/worktrees/reverent-proskuriakova added at 19b1f26254

Submodule .claude/worktrees/suspicious-wilson added at 707f632d34

32
.stignore Normal file
View File

@@ -0,0 +1,32 @@
// Git internals - never sync
.git
// Dependencies
node_modules
// Next.js build output
.next
out
// Local env files with secrets
.env
.env.local
.env.*.local
// Database files (use migrations, not the file)
*.db
*.db-journal
*.db-shm
*.db-wal
// Build artifacts
build
tsconfig.tsbuildinfo
// Syncthing own temp files (safety net)
~syncthing~*
.syncthing.*
// OS junk
.DS_Store
Thumbs.db

View File

@@ -1,5 +1,4 @@
FROM node:20-alpine AS base
RUN apk update && apk upgrade --no-cache
# Install dependencies only when needed
FROM base AS deps
@@ -7,7 +6,7 @@ RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
RUN npm install
# Rebuild the source code only when needed
FROM base AS builder
@@ -35,9 +34,6 @@ ENV DATABASE_URL="file:/app/data/dev.db"
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Create data directory for SQLite and set permissions
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
@@ -48,6 +44,11 @@ RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
# Create data directory AFTER all copies so permissions are never clobbered
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data && chmod 700 /app/data
USER nextjs
@@ -58,7 +59,10 @@ ENV PORT=3000
# script to run migrations before starting
COPY --chown=nextjs:nodejs <<EOF /app/entrypoint.sh
#!/bin/sh
set -e
echo "Running prisma db push..."
npx prisma db push --accept-data-loss
echo "Starting server..."
node server.js
EOF

View File

@@ -42,7 +42,7 @@ docker run -p 3000:3000 \
```
## 🏡 Unraid Installation
For specific instructions on installing this on Unraid (including volume mapping and Unraid UI configuration), please refer to our [Unraid Installation Guide](C:\Users\stedw\.gemini\antigravity\brain\26965ef4-0e57-4fac-9aaf-0111085e228b\unraid_install.md).
For specific instructions on installing this on Unraid (including volume mapping and Unraid UI configuration), please refer to our [Unraid Installation Guide](https://git.alwisp.com/jason/wfh/src/branch/master/unraid_install.md).
## 🛠️ Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router)

BIN
dev.db

Binary file not shown.

View File

@@ -1,12 +1,12 @@
/** @type {import("next").NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["@prisma/client", "prisma"],
serverExternalPackages: ["@prisma/client", "prisma", "@prisma/adapter-libsql", "@libsql/client", "libsql"],
webpack: (config, { isServer }) => {
if (isServer) {
// Ensure Prisma is never bundled by webpack
// Ensure Prisma and libsql are never bundled by webpack
const existingExternals = Array.isArray(config.externals) ? config.externals : [];
config.externals = [...existingExternals, '@prisma/client', 'prisma'];
config.externals = [...existingExternals, '@prisma/client', 'prisma', '@prisma/adapter-libsql', '@libsql/client', 'libsql'];
}
return config;
},

333
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-libsql": "^7.5.0",
"@prisma/client": "^7.5.0",
"googleapis": "^171.4.0",
"lucide-react": "^0.577.0",
@@ -1125,6 +1126,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@libsql/isomorphic-ws": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz",
"integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==",
"license": "MIT",
"dependencies": {
"@types/ws": "^8.5.4",
"ws": "^8.13.0"
}
},
"node_modules/@libsql/linux-arm-gnueabihf": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.22.tgz",
"integrity": "sha512-3Uo3SoDPJe/zBnyZKosziRGtszXaEtv57raWrZIahtQDsjxBVjuzYQinCm9LRCJCUT5t2r5Z5nLDPJi2CwZVoA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@libsql/linux-arm-musleabihf": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.22.tgz",
"integrity": "sha512-LCsXh07jvSojTNJptT9CowOzwITznD+YFGGW+1XxUr7fS+7/ydUrpDfsMX7UqTqjm7xG17eq86VkWJgHJfvpNg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@mrleebo/prisma-ast": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz",
@@ -1151,6 +1188,12 @@
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@neon-rs/load": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz",
"integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==",
"license": "MIT"
},
"node_modules/@next-auth/prisma-adapter": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz",
@@ -1372,6 +1415,183 @@
"node": ">=14"
}
},
"node_modules/@prisma/adapter-libsql": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-libsql/-/adapter-libsql-7.5.0.tgz",
"integrity": "sha512-zcZCH/sq/ErqhOXw+8/2IOKSZVMym7LUEEVxq/cbv5MMUl1qciPJsam0JkMOsZbE+urxM6QK2yQ0Zdp/uOEKhw==",
"license": "Apache-2.0",
"dependencies": {
"@libsql/client": "^0.17.0",
"@prisma/driver-adapter-utils": "7.5.0",
"async-mutex": "0.5.0"
}
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/client": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz",
"integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==",
"license": "MIT",
"dependencies": {
"@libsql/core": "^0.17.0",
"@libsql/hrana-client": "^0.9.0",
"js-base64": "^3.7.5",
"libsql": "^0.5.22",
"promise-limit": "^2.7.0"
}
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/core": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.0.tgz",
"integrity": "sha512-hnZRnJHiS+nrhHKLGYPoJbc78FE903MSDrFJTbftxo+e52X+E0Y0fHOCVYsKWcg6XgB7BbJYUrz/xEkVTSaipw==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/darwin-arm64": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.22.tgz",
"integrity": "sha512-4B8ZlX3nIDPndfct7GNe0nI3Yw6ibocEicWdC4fvQbSs/jdq/RC2oCsoJxJ4NzXkvktX70C1J4FcmmoBy069UA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/darwin-x64": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.22.tgz",
"integrity": "sha512-ny2HYWt6lFSIdNFzUFIJ04uiW6finXfMNJ7wypkAD8Pqdm6nAByO+Fdqu8t7sD0sqJGeUCiOg480icjyQ2/8VA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/hrana-client": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.9.0.tgz",
"integrity": "sha512-pxQ1986AuWfPX4oXzBvLwBnfgKDE5OMhAdR/5cZmRaB4Ygz5MecQybvwZupnRz341r2CtFmbk/BhSu7k2Lm+Jw==",
"license": "MIT",
"dependencies": {
"@libsql/isomorphic-ws": "^0.1.5",
"cross-fetch": "^4.0.0",
"js-base64": "^3.7.5",
"node-fetch": "^3.3.2"
}
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/linux-arm64-gnu": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.22.tgz",
"integrity": "sha512-KSdnOMy88c9mpOFKUEzPskSaF3VLflfSUCBwas/pn1/sV3pEhtMF6H8VUCd2rsedwoukeeCSEONqX7LLnQwRMA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/linux-arm64-musl": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.22.tgz",
"integrity": "sha512-mCHSMAsDTLK5YH//lcV3eFEgiR23Ym0U9oEvgZA0667gqRZg/2px+7LshDvErEKv2XZ8ixzw3p1IrBzLQHGSsw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/linux-x64-gnu": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.22.tgz",
"integrity": "sha512-kNBHaIkSg78Y4BqAdgjcR2mBilZXs4HYkAmi58J+4GRwDQZh5fIUWbnQvB9f95DkWUIGVeenqLRFY2pcTmlsew==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/linux-x64-musl": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.22.tgz",
"integrity": "sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@prisma/adapter-libsql/node_modules/@libsql/win32-x64-msvc": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.22.tgz",
"integrity": "sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@prisma/adapter-libsql/node_modules/detect-libc": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
"integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/@prisma/adapter-libsql/node_modules/libsql": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.22.tgz",
"integrity": "sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==",
"cpu": [
"x64",
"arm64",
"wasm32",
"arm"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"@neon-rs/load": "^0.0.4",
"detect-libc": "2.0.2"
},
"optionalDependencies": {
"@libsql/darwin-arm64": "0.5.22",
"@libsql/darwin-x64": "0.5.22",
"@libsql/linux-arm-gnueabihf": "0.5.22",
"@libsql/linux-arm-musleabihf": "0.5.22",
"@libsql/linux-arm64-gnu": "0.5.22",
"@libsql/linux-arm64-musl": "0.5.22",
"@libsql/linux-x64-gnu": "0.5.22",
"@libsql/linux-x64-musl": "0.5.22",
"@libsql/win32-x64-msvc": "0.5.22"
}
},
"node_modules/@prisma/client": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.5.0.tgz",
@@ -1445,6 +1665,15 @@
"zeptomatch": "2.1.0"
}
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.5.0.tgz",
"integrity": "sha512-B79N/amgV677mFesFDBAdrW0OIaqawap9E0sjgLBtzIz2R3hIMS1QB8mLZuUEiS4q5Y8Oh3I25Kw4SLxMypk9Q==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.5.0"
}
},
"node_modules/@prisma/engines": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.5.0.tgz",
@@ -1858,7 +2087,6 @@
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -1883,6 +2111,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
@@ -2717,6 +2954,15 @@
"node": ">= 0.4"
}
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -3096,6 +3342,35 @@
"node": ">= 0.6"
}
},
"node_modules/cross-fetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-fetch/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5301,6 +5576,12 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6655,6 +6936,12 @@
}
}
},
"node_modules/promise-limit": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz",
"integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==",
"license": "ISC"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7687,6 +7974,12 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -7884,7 +8177,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -8001,6 +8293,22 @@
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8203,6 +8511,27 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -9,7 +9,6 @@
"lint": "eslint"
},
"dependencies": {
"@libsql/client": "^0.14.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-libsql": "^7.5.0",
"@prisma/client": "^7.5.0",

View File

@@ -1,16 +1,9 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
import { createClient } from "@libsql/client";
import { PrismaLibSQL } from "@prisma/adapter-libsql";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrate: {
adapter: async () => {
const libsql = createClient({
url: process.env.DATABASE_URL ?? "file:./dev.db",
});
return new PrismaLibSQL(libsql);
},
datasource: {
url: env("DATABASE_URL") ?? "file:./dev.db",
},
});

View File

@@ -66,6 +66,7 @@ model Report {
date DateTime @default(now())
managerName String
status ReportStatus @default(IN_PROGRESS)
driveFileId String?
userId String
user User @relation(fields: [userId], references: [id])
tasks Task[]

View File

@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// GET /api/admin/users - List all users
export async function GET() {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
reports: {
orderBy: { date: "desc" },
take: 1,
select: { date: true, status: true },
},
},
orderBy: { name: "asc" },
});
return NextResponse.json(users);
}
// PATCH /api/admin/users - Update a user's role
export async function PATCH(req: Request) {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId, role } = await req.json();
if (!userId || !["EMPLOYEE", "ADMIN"].includes(role)) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
// Prevent admins from demoting themselves
if (userId === session.user.id && role === "EMPLOYEE") {
return NextResponse.json({ error: "You cannot remove your own admin privileges" }, { status: 403 });
}
const updated = await prisma.user.update({
where: { id: userId },
data: { role },
select: { id: true, name: true, email: true, role: true },
});
return NextResponse.json(updated);
}

View File

@@ -2,12 +2,10 @@ import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { uploadToDrive, generateReportMarkdown } from "@/lib/google-drive";
import { getToken } from "next-auth/jwt";
import { uploadToDrive, updateDriveFile, generateReportHTML, getGoogleAuth } from "@/lib/google-drive";
export async function POST(
req: Request,
@@ -15,11 +13,16 @@ export async function POST(
) {
const { id } = await params;
const session = await getServerSession(authOptions);
// We need the raw access token from JWT for Google API
const token = await getToken({ req: req as any });
if (!session || !token?.accessToken) {
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let auth;
try {
auth = await getGoogleAuth(session.user.id);
} catch (error) {
console.error("Failed to get Google Auth:", error);
return NextResponse.json({ error: "Unauthorized or missing Google token" }, { status: 401 });
}
@@ -32,7 +35,7 @@ export async function POST(
return NextResponse.json({ error: "Report not found" }, { status: 404 });
}
const markdown = generateReportMarkdown(report);
const htmlContent = generateReportHTML(report);
const fileName = `WFH_Report_${new Date(report.date).toISOString().split('T')[0]}_${report.user.name}`;
// Fetch designated folder ID from settings
@@ -41,17 +44,23 @@ export async function POST(
});
try {
const driveFile = await uploadToDrive(
token.accessToken as string,
fileName,
markdown,
folderSetting?.value
);
// Update report status to SUBMITTED
let driveFile;
if (report.driveFileId) {
// Update the existing Drive file in place
driveFile = await updateDriveFile(auth, report.driveFileId, htmlContent);
} else {
// First export — create a new Drive file and store its ID
driveFile = await uploadToDrive(auth, fileName, htmlContent, folderSetting?.value);
await prisma.report.update({
where: { id },
data: { driveFileId: driveFile.id },
});
}
await prisma.report.update({
where: { id },
data: { status: 'SUBMITTED' }
data: { status: 'SUBMITTED' },
});
return NextResponse.json({ success: true, link: driveFile.webViewLink });

View File

@@ -19,7 +19,7 @@ export async function GET() {
const reports = await prisma.report.findMany({
where,
include: { tasks: true },
include: { tasks: true, user: true },
orderBy: { date: "desc" },
});
@@ -37,9 +37,12 @@ export async function POST(req: Request) {
const body = await req.json();
const { managerName, date } = body;
// Check if a report already exists for this date and user
const reportDate = date ? new Date(date) : new Date();
reportDate.setHours(0, 0, 0, 0);
// Check if a report already exists for this date and user.
// Client always sends a YYYY-MM-DD date string in Central US time;
// we store it as UTC midnight so the date string is stable across timezones.
const reportDate = date
? new Date(`${date}T00:00:00.000Z`)
: new Date(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }) + 'T00:00:00.000Z');
let report = await prisma.report.findFirst({
where: {

View File

@@ -1,16 +1,19 @@
"use client";
import { useState, useEffect } from "react";
import { Search, ChevronDown, ChevronUp, ExternalLink, FileText, User, Calendar, Settings, ListChecks, Save } from "lucide-react";
import { Search, ChevronDown, ChevronUp, ExternalLink, FileText, User, Users, Calendar, Settings, ListChecks, Save, ShieldCheck, ShieldOff } from "lucide-react";
export default function AdminDashboard() {
const [reports, setReports] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [tab, setTab] = useState<"REPORTS" | "SETTINGS">("REPORTS");
const [tab, setTab] = useState<"REPORTS" | "USERS" | "SETTINGS">("REPORTS");
const [folderId, setFolderId] = useState("");
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<any[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
useEffect(() => {
fetchReports();
@@ -45,6 +48,41 @@ export default function AdminDashboard() {
}
};
const fetchUsers = async () => {
setUsersLoading(true);
try {
const res = await fetch("/api/admin/users");
const data = await res.json();
setUsers(data);
} catch (error) {
console.error("Failed to fetch users");
} finally {
setUsersLoading(false);
}
};
const toggleRole = async (userId: string, currentRole: string) => {
setTogglingId(userId);
const newRole = currentRole === "ADMIN" ? "EMPLOYEE" : "ADMIN";
try {
const res = await fetch("/api/admin/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, role: newRole }),
});
const data = await res.json();
if (data.error) {
alert(data.error);
} else {
setUsers(users.map(u => u.id === userId ? { ...u, role: newRole } : u));
}
} catch (error) {
alert("Failed to update role");
} finally {
setTogglingId(null);
}
};
const fetchReports = async () => {
try {
const res = await fetch("/api/reports");
@@ -76,7 +114,15 @@ export default function AdminDashboard() {
>
<ListChecks size={18} /> Reports
</button>
<button
<button
onClick={() => { setTab("USERS"); if (!users.length) fetchUsers(); }}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm font-medium ${
tab === "USERS" ? "bg-accent-primary text-white" : "hover:bg-white/5 text-text-dim"
}`}
>
<Users size={18} /> Users
</button>
<button
onClick={() => setTab("SETTINGS")}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm font-medium ${
tab === "SETTINGS" ? "bg-accent-primary text-white" : "hover:bg-white/5 text-text-dim"
@@ -164,6 +210,81 @@ export default function AdminDashboard() {
))}
{filteredReports.length === 0 && <p className="text-center py-10 text-text-dim">No reports found matching your criteria.</p>}
</div>
) : tab === "USERS" ? (
<div className="space-y-4 animate-fade-in">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<Users size={20} className="text-accent-primary" /> Employee List
</h3>
<p className="text-sm text-text-dim">Manage admin privileges for your team.</p>
</div>
{usersLoading ? (
<div className="text-center py-10 animate-pulse text-text-dim">Loading users...</div>
) : (
<div className="grid gap-3">
{users.map((user) => {
const lastReport = user.reports?.[0];
return (
<div key={user.id} className="glass-card p-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
{user.image ? (
<img src={user.image} alt={user.name} className="h-10 w-10 rounded-full flex-shrink-0" />
) : (
<div className="h-10 w-10 rounded-full bg-accent-primary/20 flex items-center justify-center text-accent-primary flex-shrink-0">
<User size={20} />
</div>
)}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold truncate">{user.name || "Unnamed"}</p>
<span className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full font-bold flex-shrink-0 ${
user.role === "ADMIN"
? "bg-accent-primary/20 text-accent-primary"
: "bg-white/10 text-text-dim"
}`}>
{user.role}
</span>
</div>
<p className="text-xs text-text-dim truncate">{user.email}</p>
{lastReport && (
<p className="text-[10px] text-text-dim mt-0.5">
Last report: {new Date(lastReport.date).toLocaleDateString()} {" "}
<span className={lastReport.status === "SUBMITTED" ? "text-green-400" : "text-yellow-400"}>
{lastReport.status}
</span>
</p>
)}
</div>
</div>
<button
onClick={() => toggleRole(user.id, user.role)}
disabled={togglingId === user.id}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex-shrink-0 disabled:opacity-50 ${
user.role === "ADMIN"
? "bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20"
: "bg-accent-primary/10 hover:bg-accent-primary/20 text-accent-primary border border-accent-primary/20"
}`}
title={user.role === "ADMIN" ? "Remove admin privileges" : "Grant admin privileges"}
>
{togglingId === user.id ? (
"..."
) : user.role === "ADMIN" ? (
<><ShieldOff size={16} /> Remove Admin</>
) : (
<><ShieldCheck size={16} /> Make Admin</>
)}
</button>
</div>
);
})}
{users.length === 0 && (
<p className="text-center py-10 text-text-dim">No users found.</p>
)}
</div>
)}
</div>
) : (
<div className="glass-card p-8 space-y-8 animate-fade-in">
<div className="space-y-4">

View File

@@ -1,7 +1,7 @@
"use client";
import { useSession, signIn, signOut } from "next-auth/react";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Plus, Trash2, Send, Save, CheckCircle, Clock, Calendar, User as UserIcon, Link as LinkIcon, LogOut, ShieldCheck, ClipboardList } from "lucide-react";
import AdminDashboard from "./AdminDashboard";
@@ -14,6 +14,8 @@ export default function ReportForm() {
const [completedTasks, setCompletedTasks] = useState<any[]>([]);
const [saving, setSaving] = useState(false);
const [view, setView] = useState<"REPORT" | "ADMIN">("REPORT");
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const pendingUpdates = useRef<Record<string, any>>({});
useEffect(() => {
if (status === "authenticated") {
@@ -23,11 +25,15 @@ export default function ReportForm() {
}
}, [status]);
// Returns today's date as YYYY-MM-DD in Central US time
const getCentralToday = () =>
new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' });
const fetchReport = async () => {
try {
const res = await fetch("/api/reports");
const data = await res.json();
const today = new Date().toISOString().split('T')[0];
const today = getCentralToday();
const todayReport = data.find((r: any) => r.date.split('T')[0] === today);
if (todayReport) {
@@ -49,7 +55,7 @@ export default function ReportForm() {
const res = await fetch("/api/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ managerName }),
body: JSON.stringify({ managerName, date: getCentralToday() }),
});
const data = await res.json();
setReport(data);
@@ -85,21 +91,33 @@ export default function ReportForm() {
}
};
const updateTask = async (id: string, updates: any) => {
try {
const updateTask = (id: string, updates: any) => {
// Update local state immediately so the UI stays responsive
if (updates.type === 'PLANNED') {
setPlannedTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
} else {
setCompletedTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
}
// Accumulate all field changes for this task so a single request carries everything
pendingUpdates.current[id] = { ...pendingUpdates.current[id], ...updates };
// Reset the debounce timer — the API call fires 600 ms after the last keystroke
clearTimeout(debounceTimers.current[id]);
debounceTimers.current[id] = setTimeout(async () => {
const payload = pendingUpdates.current[id];
delete pendingUpdates.current[id];
delete debounceTimers.current[id];
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...updates }),
body: JSON.stringify({ id, ...payload }),
});
if (updates.type === 'PLANNED') {
setPlannedTasks(plannedTasks.map(t => t.id === id ? { ...t, ...updates } : t));
} else {
setCompletedTasks(completedTasks.map(t => t.id === id ? { ...t, ...updates } : t));
}
} catch (error) {
console.error("Failed to update task");
}
}, 600);
};
const deleteTask = async (id: string, type: string) => {
@@ -314,12 +332,12 @@ export default function ReportForm() {
</section>
<footer className="pt-8 border-t border-white/10 flex justify-end gap-4">
<button
<button
onClick={exportToDrive}
disabled={saving || report.status === 'SUBMITTED'}
disabled={saving}
className="btn-primary flex items-center gap-2 px-8"
>
{saving ? "Processing..." : (report.status === 'SUBMITTED' ? "Already Submitted" : "Finalize & Export to Drive")}
{saving ? "Processing..." : (report.status === 'SUBMITTED' ? "Re-export to Drive" : "Finalize & Export to Drive")}
<Send size={18} />
</button>
</footer>

View File

@@ -1,10 +1,56 @@
import { google } from 'googleapis';
import { Readable } from 'stream';
import { prisma } from './prisma';
export async function uploadToDrive(accessToken: string, fileName: string, content: string, folderId?: string) {
const auth = new google.auth.OAuth2();
auth.setCredentials({ access_token: accessToken });
export async function getGoogleAuth(userId: string) {
const account = await prisma.account.findFirst({
where: { userId, provider: 'google' },
});
if (!account) {
throw new Error('Google account not found');
}
const auth = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET
);
auth.setCredentials({
access_token: account.access_token,
refresh_token: account.refresh_token,
expiry_date: account.expires_at ? account.expires_at * 1000 : null,
});
// Check if the token is expired or will expire in the next 1 minute
// NextAuth stores expires_at in seconds
const isExpired = account.expires_at ? (account.expires_at * 1000) < (Date.now() + 60000) : true;
if (isExpired && account.refresh_token) {
try {
const { credentials } = await auth.refreshAccessToken();
auth.setCredentials(credentials);
// Update database with new tokens
await prisma.account.update({
where: { id: account.id },
data: {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token || account.refresh_token,
expires_at: credentials.expiry_date ? Math.floor(credentials.expiry_date / 1000) : null,
},
});
console.log('Successfully refreshed Google access token for user:', userId);
} catch (error) {
console.error('Error refreshing access token:', error);
// If refresh fails, we still return the auth object, but requests will fail with 401
}
}
return auth;
}
export async function uploadToDrive(auth: any, fileName: string, content: string, folderId?: string) {
const drive = google.drive({ version: 'v3', auth });
const fileMetadata: any = {
@@ -17,7 +63,7 @@ export async function uploadToDrive(accessToken: string, fileName: string, conte
}
const media = {
mimeType: 'text/markdown',
mimeType: 'text/html',
body: Readable.from([content]),
};
@@ -34,23 +80,87 @@ export async function uploadToDrive(accessToken: string, fileName: string, conte
}
}
export function generateReportMarkdown(report: any) {
let md = `# WFH Daily Report - ${new Date(report.date).toLocaleDateString()}\n`;
md += `**Employee:** ${report.user.name}\n`;
md += `**Manager:** ${report.managerName}\n\n`;
export async function updateDriveFile(auth: any, fileId: string, content: string) {
const drive = google.drive({ version: 'v3', auth });
md += `## Planned Tasks\n`;
report.tasks.filter((t: any) => t.type === 'PLANNED').forEach((t: any) => {
md += `- [ ] ${t.description} (Est: ${t.timeEstimate})\n`;
if (t.notes) md += ` - Notes: ${t.notes}\n`;
});
const media = {
mimeType: 'text/html',
body: Readable.from([content]),
};
md += `\n## Completed Tasks\n`;
report.tasks.filter((t: any) => t.type === 'COMPLETED').forEach((t: any) => {
md += `- [x] ${t.description}\n`;
md += ` - Status: ${t.status}\n`;
if (t.link) md += ` - Work Link: ${t.link}\n`;
});
return md;
try {
const response = await drive.files.update({
fileId,
media,
fields: 'id, webViewLink',
});
return response.data;
} catch (error) {
console.error('Error updating Google Drive file:', error);
throw error;
}
}
export function generateReportHTML(report: any) {
const dateObj = new Date(report.date);
const dateStr = dateObj.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const plannedTasks = report.tasks.filter((t: any) => t.type === 'PLANNED');
const completedTasks = report.tasks.filter((t: any) => t.type === 'COMPLETED');
const cellStyle = "padding: 10px; border-bottom: 1px solid #e2e8f0; font-family: Arial, sans-serif; font-size: 11pt;";
const headerStyle = "padding: 12px 10px; background-color: #f1f5f9; border-bottom: 2px solid #cbd5e1; font-family: Arial, sans-serif; font-size: 11pt; font-weight: bold; text-align: left; color: #334155;";
return `
<html>
<body style="font-family: Arial, sans-serif; color: #334155; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px;">
<h1 style="color: #0f172a; border-bottom: 3px solid #3b82f6; padding-bottom: 10px; font-family: Arial, sans-serif; margin-bottom: 20px;">WFH Daily Report</h1>
<div style="background-color: #f8fafc; padding: 20px; border-left: 4px solid #3b82f6; border-radius: 4px; margin-bottom: 30px; font-family: Arial, sans-serif;">
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Date:</strong> ${dateStr}</p>
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Employee:</strong> ${report.user.name}</p>
<p style="margin: 0; font-size: 11pt;"><strong>Manager:</strong> ${report.managerName || 'N/A'}</p>
</div>
<h2 style="color: #1e293b; margin-top: 30px; margin-bottom: 15px; font-family: Arial, sans-serif;">Planned Tasks</h2>
${plannedTasks.length > 0 ? `
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
<tr>
<th style="${headerStyle} width: 45%;">Description</th>
<th style="${headerStyle} width: 20%;">Estimate</th>
<th style="${headerStyle} width: 35%;">Notes</th>
</tr>
${plannedTasks.map((t: any) => `
<tr>
<td style="${cellStyle}">${t.description}</td>
<td style="${cellStyle} color: #64748b;">${t.timeEstimate || '-'}</td>
<td style="${cellStyle} color: #64748b;">${t.notes || '-'}</td>
</tr>
`).join('')}
</table>
` : `<p style="font-style: italic; color: #94a3b8; font-family: Arial, sans-serif; margin-bottom: 30px;">No planned tasks for today.</p>`}
<h2 style="color: #1e293b; margin-top: 30px; margin-bottom: 15px; font-family: Arial, sans-serif;">Completed Tasks</h2>
${completedTasks.length > 0 ? `
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
<tr>
<th style="${headerStyle} width: 40%;">Description</th>
<th style="${headerStyle} width: 20%;">Status</th>
<th style="${headerStyle} width: 40%;">Work Link</th>
</tr>
${completedTasks.map((t: any) => `
<tr>
<td style="${cellStyle}">${t.description}</td>
<td style="${cellStyle} font-weight: bold; color: #059669;">${t.status || 'Done'}</td>
<td style="${cellStyle}">${t.link ? `<a href="${t.link}" style="color: #2563eb; text-decoration: none;">${t.link}</a>` : '-'}</td>
</tr>
`).join('')}
</table>
` : `<p style="font-style: italic; color: #94a3b8; font-family: Arial, sans-serif; margin-bottom: 30px;">No completed tasks reported today.</p>`}
<div style="margin-top: 50px; font-size: 9pt; color: #cbd5e1; text-align: center; border-top: 1px solid #e2e8f0; padding-top: 20px; font-family: Arial, sans-serif;">
Generated automatically by WFH App
</div>
</body>
</html>
`;
}

View File

@@ -1,6 +1,5 @@
import { PrismaClient } from '@prisma/client'
import { PrismaLibSQL } from '@prisma/adapter-libsql'
import { createClient } from '@libsql/client'
import { PrismaLibSql } from '@prisma/adapter-libsql'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
@@ -8,10 +7,9 @@ const globalForPrisma = globalThis as unknown as {
function getPrismaClient(): PrismaClient {
if (!globalForPrisma.prisma) {
const libsql = createClient({
const adapter = new PrismaLibSql({
url: process.env.DATABASE_URL ?? 'file:./dev.db',
})
const adapter = new PrismaLibSQL(libsql)
globalForPrisma.prisma = new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],

View File

@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "prisma.config.ts"]
}