basic working app currently wworking on the sync to work
Build APK / build (push) Canceled after 0s

This commit is contained in:
2026-08-06 11:16:47 +02:00
commit 793e2d9044
192 changed files with 42523 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
- "dev/logs/": contains structured JSONL event logs from CLI commands (e.g. start.log, export.log). These are truncated on each run.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
+67
View File
@@ -0,0 +1,67 @@
{"_e":"root:init","_t":1785916376169,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1785916376170,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1785916376172,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1785916376448,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1785916376677,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1785916376835,"plugins":[{"packageName":"expo-sqlite","bannerTitle":"expo-sqlite","cliBanner":false,"webpageEndpoint":"/_expo/plugins/expo-sqlite"}]}
{"_e":"metro:instantiate","_t":1785916381752,"atlas":false,"workers":10,"host":"::","port":8081}
{"_e":"env:mode","_t":1785916385293,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"metro:bundling:started","_t":1785916393731,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:progress","_t":1785916393732,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785916393735,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785916394183,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:failed","_t":1785916394196,"id":"1","message":"Unable to resolve \"../../App\" from \"node_modules/expo/AppEntry.js\"\n 1 | import registerRootComponent from 'expo/src/launch/registerRootComponent';\n 2 |\n> 3 | import App from '../../App';\n | ^\n 4 |\n 5 | registerRootComponent(App);\n 6 |","importStack":"\u001b[1mImport stack:\u001b[22m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90mnode_modules/\u001b[1mexpo\u001b[22m/AppEntry.js\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"../../App\"\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"./node_modules/\u001b[1mexpo\u001b[22m/AppEntry\"\u001b[39m\u001b[90m\u001b[39m\n","filename":null,"targetModuleName":"../../App","originModulePath":"node_modules/expo/AppEntry.js"}
{"_e":"root:init","_t":1785916420691,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1785916420692,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1785916420693,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1785916420915,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1785916421111,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1785916421255,"plugins":[{"packageName":"expo-sqlite","bannerTitle":"expo-sqlite","cliBanner":false,"webpageEndpoint":"/_expo/plugins/expo-sqlite"}]}
{"_e":"metro:instantiate","_t":1785916426248,"atlas":false,"workers":10,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1785916428932,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:progress","_t":1785916428932,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785916428936,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:failed","_t":1785916428952,"id":"1","message":"Unable to resolve \"../../App\" from \"node_modules/expo/AppEntry.js\"\n 1 | import registerRootComponent from 'expo/src/launch/registerRootComponent';\n 2 |\n> 3 | import App from '../../App';\n | ^\n 4 |\n 5 | registerRootComponent(App);\n 6 |","importStack":"\u001b[1mImport stack:\u001b[22m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90mnode_modules/\u001b[1mexpo\u001b[22m/AppEntry.js\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"../../App\"\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"./node_modules/\u001b[1mexpo\u001b[22m/AppEntry\"\u001b[39m\u001b[90m\u001b[39m\n","filename":null,"targetModuleName":"../../App","originModulePath":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:started","_t":1785916436820,"id":"3","platform":"web","environment":null,"entry":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:progress","_t":1785916436820,"id":"3","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785916436822,"id":"3","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:failed","_t":1785916436826,"id":"3","message":"Unable to resolve \"../../App\" from \"node_modules/expo/AppEntry.js\"\n 1 | import registerRootComponent from 'expo/src/launch/registerRootComponent';\n 2 |\n> 3 | import App from '../../App';\n | ^\n 4 |\n 5 | registerRootComponent(App);\n 6 |","importStack":"\u001b[1mImport stack:\u001b[22m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90mnode_modules/\u001b[1mexpo\u001b[22m/AppEntry.js\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"../../App\"\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"./node_modules/\u001b[1mexpo\u001b[22m/AppEntry\"\u001b[39m\u001b[90m\u001b[39m\n","filename":null,"targetModuleName":"../../App","originModulePath":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:started","_t":1785916436951,"id":"4","platform":"web","environment":null,"entry":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:progress","_t":1785916436951,"id":"4","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785916436953,"id":"4","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:failed","_t":1785916436957,"id":"4","message":"Unable to resolve \"../../App\" from \"node_modules/expo/AppEntry.js\"\n 1 | import registerRootComponent from 'expo/src/launch/registerRootComponent';\n 2 |\n> 3 | import App from '../../App';\n | ^\n 4 |\n 5 | registerRootComponent(App);\n 6 |","importStack":"\u001b[1mImport stack:\u001b[22m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90mnode_modules/\u001b[1mexpo\u001b[22m/AppEntry.js\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"../../App\"\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"./node_modules/\u001b[1mexpo\u001b[22m/AppEntry\"\u001b[39m\u001b[90m\u001b[39m\n","filename":null,"targetModuleName":"../../App","originModulePath":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:started","_t":1785916439798,"id":"6","platform":"web","environment":null,"entry":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:progress","_t":1785916439799,"id":"6","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785916439800,"id":"6","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:failed","_t":1785916439802,"id":"6","message":"Unable to resolve \"../../App\" from \"node_modules/expo/AppEntry.js\"\n 1 | import registerRootComponent from 'expo/src/launch/registerRootComponent';\n 2 |\n> 3 | import App from '../../App';\n | ^\n 4 |\n 5 | registerRootComponent(App);\n 6 |","importStack":"\u001b[1mImport stack:\u001b[22m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90mnode_modules/\u001b[1mexpo\u001b[22m/AppEntry.js\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"../../App\"\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"./node_modules/\u001b[1mexpo\u001b[22m/AppEntry\"\u001b[39m\u001b[90m\u001b[39m\n","filename":null,"targetModuleName":"../../App","originModulePath":"node_modules/expo/AppEntry.js"}
{"_e":"root:init","_t":1785952835728,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1785952835729,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1785952835730,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1785952835948,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1785952836178,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1785952836343,"plugins":[{"packageName":"expo-sqlite","bannerTitle":"expo-sqlite","cliBanner":false,"webpageEndpoint":"/_expo/plugins/expo-sqlite"}]}
{"_e":"metro:instantiate","_t":1785952841798,"atlas":false,"workers":10,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1785952842080,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:progress","_t":1785952842080,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785952842084,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785952842408,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:failed","_t":1785952842415,"id":"1","message":"Unable to resolve \"../../App\" from \"node_modules/expo/AppEntry.js\"\n 1 | import registerRootComponent from 'expo/src/launch/registerRootComponent';\n 2 |\n> 3 | import App from '../../App';\n | ^\n 4 |\n 5 | registerRootComponent(App);\n 6 |","importStack":"\u001b[1mImport stack:\u001b[22m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90mnode_modules/\u001b[1mexpo\u001b[22m/AppEntry.js\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"../../App\"\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"./node_modules/\u001b[1mexpo\u001b[22m/AppEntry\"\u001b[39m\u001b[90m\u001b[39m\n","filename":null,"targetModuleName":"../../App","originModulePath":"node_modules/expo/AppEntry.js"}
{"_e":"root:init","_t":1785999547746,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1785999547746,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1785999547749,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1785999547965,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1785999548198,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1785999548371,"plugins":[{"packageName":"expo-sqlite","bannerTitle":"expo-sqlite","cliBanner":false,"webpageEndpoint":"/_expo/plugins/expo-sqlite"}]}
{"_e":"metro:instantiate","_t":1785999554987,"atlas":false,"workers":10,"host":"::","port":8081}
{"_e":"root:init","_t":1785999555771,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1785999555772,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1785999555773,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1785999555929,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1785999556084,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1785999556214,"plugins":[{"packageName":"expo-sqlite","bannerTitle":"expo-sqlite","cliBanner":false,"webpageEndpoint":"/_expo/plugins/expo-sqlite"}]}
{"_e":"metro:instantiate","_t":1785999562653,"atlas":false,"workers":10,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1785999562966,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo/AppEntry.js"}
{"_e":"metro:bundling:progress","_t":1785999562967,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785999562971,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1785999563294,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:failed","_t":1785999563301,"id":"1","message":"Unable to resolve \"../../App\" from \"node_modules/expo/AppEntry.js\"\n 1 | import registerRootComponent from 'expo/src/launch/registerRootComponent';\n 2 |\n> 3 | import App from '../../App';\n | ^\n 4 |\n 5 | registerRootComponent(App);\n 6 |","importStack":"\u001b[1mImport stack:\u001b[22m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90mnode_modules/\u001b[1mexpo\u001b[22m/AppEntry.js\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"../../App\"\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m \u001b[39m\u001b[90m\u001b[39m\n\u001b[90m\u001b[90m |\u001b[39m\u001b[90m \u001b[36mimport\u001b[39m\u001b[90m \u001b[32m\"./node_modules/\u001b[1mexpo\u001b[22m/AppEntry\"\u001b[39m\u001b[90m\u001b[39m\n","filename":null,"targetModuleName":"../../App","originModulePath":"node_modules/expo/AppEntry.js"}
+3
View File
@@ -0,0 +1,3 @@
{
"devices": []
}
+63
View File
@@ -0,0 +1,63 @@
name: Build APK
on:
push:
branches: [main]
workflow_dispatch:
env:
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Install Android SDK
run: |
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" \
"platforms;android-35" \
"build-tools;35.0.0" \
"ndk;27.1.12297006"
- name: Install JS dependencies
working-directory: carry-your-live
run: npm ci
- name: Generate debug keystore (signs the release APK)
run: |
keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \
-alias androiddebugkey -storepass android -keypass android \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"
- name: Build release APK
working-directory: carry-your-live/android
run: ./gradlew assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: carry-your-live-release
path: carry-your-live/android/app/build/outputs/apk/release/*.apk
+1
View File
@@ -0,0 +1 @@
node_modules
+646
View File
@@ -0,0 +1,646 @@
# API Specification
## Base URL
```
Development: http://localhost:3000/api
Production: https://api.carryyourlive.com/api
```
## Authentication
All endpoints require authentication via Bearer token:
```
Authorization: Bearer <jwt_token>
```
## Endpoints
### Sync
#### Pull Changes
```
GET /sync
```
**Query Parameters**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| since | integer | Yes | Unix timestamp (ms) of last successful sync |
**Response** (200 OK)
```json
{
"categories": [
{
"id": "cat_1",
"name": "Work",
"color": "#E53935",
"order": 0,
"createdAt": 1699000000000,
"updatedAt": 1699000000000
}
],
"tasks": [
{
"id": "task_1",
"title": "Finish project",
"description": "Complete the mobile app",
"categoryId": "cat_1",
"priority": "high",
"completed": false,
"dueDate": 1699100000000,
"dueTime": "17:00",
"createdAt": 1699000000000,
"updatedAt": 1699000000000
}
],
"subtasks": [
{
"id": "sub_1",
"taskId": "task_1",
"title": "Setup Expo project",
"completed": true,
"order": 0,
"createdAt": 1699000000000,
"updatedAt": 1699000000000
}
],
"timestamp": 1699150000000
}
```
**Error Responses**
| Status | Code | Message |
|--------|------|---------|
| 400 | INVALID_TIMESTAMP | Invalid or missing `since` parameter |
| 401 | UNAUTHORIZED | Missing or invalid token |
| 500 | SERVER_ERROR | Internal server error |
---
#### Push Changes
```
POST /sync/push
```
**Request Body**
```json
{
"changes": {
"categories": [
{
"id": "cat_new",
"name": "New Category",
"color": "#1E88E5",
"order": 6,
"createdAt": 1699150000000,
"updatedAt": 1699150000000
}
],
"tasks": [
{
"id": "task_new",
"title": "New Task",
"description": "Task description",
"categoryId": "cat_1",
"priority": "medium",
"completed": false,
"dueDate": 1699200000000,
"dueTime": "10:00",
"createdAt": 1699150000000,
"updatedAt": 1699150000000
}
],
"subtasks": [
{
"id": "sub_new",
"taskId": "task_new",
"title": "Subtask 1",
"completed": false,
"order": 0,
"createdAt": 1699150000000,
"updatedAt": 1699150000000
}
]
},
"lastPulledAt": 1699100000000
}
```
**Response** (200 OK)
```json
{
"success": true,
"timestamp": 1699150000000,
"conflicts": [
{
"entity": "tasks",
"id": "task_1",
"serverVersion": { ... },
"clientVersion": { ... },
"resolution": "server_wins"
}
]
}
```
**Error Responses**
| Status | Code | Message |
|--------|------|---------|
| 400 | INVALID_PAYLOAD | Malformed request body |
| 401 | UNAUTHORIZED | Missing or invalid token |
| 409 | CONFLICT | Unresolvable conflicts (if not using auto-resolution) |
| 500 | SERVER_ERROR | Internal server error |
---
### Categories
#### List Categories
```
GET /categories
```
**Response** (200 OK)
```json
{
"categories": [
{
"id": "cat_1",
"name": "Work",
"color": "#E53935",
"order": 0,
"createdAt": 1699000000000,
"updatedAt": 1699000000000
}
]
}
```
#### Create Category
```
POST /categories
```
**Request Body**
```json
{
"name": "New Category",
"color": "#1E88E5",
"order": 6
}
```
**Response** (201 Created)
```json
{
"id": "cat_new",
"name": "New Category",
"color": "#1E88E5",
"order": 6,
"createdAt": 1699150000000,
"updatedAt": 1699150000000
}
```
#### Update Category
```
PATCH /categories/{id}
```
**Request Body**
```json
{
"name": "Updated Name",
"color": "#FB8C00",
"order": 1
}
```
**Response** (200 OK)
```json
{
"id": "cat_1",
"name": "Updated Name",
"color": "#FB8C00",
"order": 1,
"createdAt": 1699000000000,
"updatedAt": 1699150000000
}
```
#### Delete Category
```
DELETE /categories/{id}
```
**Response** (204 No Content)
---
### Tasks
#### List Tasks
```
GET /tasks
```
**Query Parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| categoryId | string | Filter by category |
| completed | boolean | Filter by completion status |
| dueBefore | integer | Due date before timestamp (ms) |
| dueAfter | integer | Due date after timestamp (ms) |
| priority | string | Filter by priority |
| sortBy | string | Sort field: dueDate, priority, createdAt, title |
| sortOrder | string | asc or desc |
| limit | integer | Pagination limit (default 50) |
| offset | integer | Pagination offset |
**Response** (200 OK)
```json
{
"tasks": [
{
"id": "task_1",
"title": "Finish project",
"description": "Complete the mobile app",
"categoryId": "cat_1",
"priority": "high",
"completed": false,
"dueDate": 1699100000000,
"dueTime": "17:00",
"createdAt": 1699000000000,
"updatedAt": 1699000000000,
"subtasks": [
{
"id": "sub_1",
"title": "Setup Expo project",
"completed": true,
"order": 0
}
]
}
],
"total": 1,
"limit": 50,
"offset": 0
}
```
#### Get Task
```
GET /tasks/{id}
```
**Response** (200 OK)
```json
{
"id": "task_1",
"title": "Finish project",
"description": "Complete the mobile app",
"categoryId": "cat_1",
"priority": "high",
"completed": false,
"dueDate": 1699100000000,
"dueTime": "17:00",
"createdAt": 1699000000000,
"updatedAt": 1699000000000,
"subtasks": [...]
}
```
#### Create Task
```
POST /tasks
```
**Request Body**
```json
{
"title": "New Task",
"description": "Task description",
"categoryId": "cat_1",
"priority": "medium",
"dueDate": 1699200000000,
"dueTime": "10:00",
"subtasks": [
{ "title": "Subtask 1" },
{ "title": "Subtask 2" }
]
}
```
**Response** (201 Created)
```json
{
"id": "task_new",
"title": "New Task",
"description": "Task description",
"categoryId": "cat_1",
"priority": "medium",
"completed": false,
"dueDate": 1699200000000,
"dueTime": "10:00",
"createdAt": 1699150000000,
"updatedAt": 1699150000000,
"subtasks": [
{ "id": "sub_1", "title": "Subtask 1", "completed": false, "order": 0 },
{ "id": "sub_2", "title": "Subtask 2", "completed": false, "order": 1 }
]
}
```
#### Update Task
```
PATCH /tasks/{id}
```
**Request Body** (all fields optional)
```json
{
"title": "Updated Title",
"description": "Updated description",
"categoryId": "cat_2",
"priority": "high",
"completed": true,
"dueDate": 1699300000000,
"dueTime": "12:00"
}
```
**Response** (200 OK)
```json
{
"id": "task_1",
"title": "Updated Title",
"description": "Updated description",
"categoryId": "cat_2",
"priority": "high",
"completed": true,
"dueDate": 1699300000000,
"dueTime": "12:00",
"createdAt": 1699000000000,
"updatedAt": 1699150000000,
"subtasks": [...]
}
```
#### Delete Task
```
DELETE /tasks/{id}
```
**Response** (204 No Content)
#### Batch Update Tasks
```
POST /tasks/batch
```
**Request Body**
```json
{
"operations": [
{ "type": "update", "id": "task_1", "data": { "completed": true } },
{ "type": "delete", "id": "task_2" },
{ "type": "create", "data": { "title": "New Task", "categoryId": "cat_1" } }
]
}
```
**Response** (200 OK)
```json
{
"results": [
{ "id": "task_1", "success": true },
{ "id": "task_2", "success": true },
{ "id": "task_new", "success": true, "data": { ... } }
]
}
```
---
### Subtasks
#### List Subtasks
```
GET /tasks/{taskId}/subtasks
```
**Response** (200 OK)
```json
{
"subtasks": [
{
"id": "sub_1",
"taskId": "task_1",
"title": "Subtask 1",
"completed": false,
"order": 0,
"createdAt": 1699000000000,
"updatedAt": 1699000000000
}
]
}
```
#### Create Subtask
```
POST /tasks/{taskId}/subtasks
```
**Request Body**
```json
{
"title": "New Subtask",
"order": 2
}
```
**Response** (201 Created)
```json
{
"id": "sub_new",
"taskId": "task_1",
"title": "New Subtask",
"completed": false,
"order": 2,
"createdAt": 1699150000000,
"updatedAt": 1699150000000
}
```
#### Update Subtask
```
PATCH /subtasks/{id}
```
**Request Body**
```json
{
"title": "Updated Subtask",
"completed": true,
"order": 1
}
```
**Response** (200 OK)
#### Delete Subtask
```
DELETE /subtasks/{id}
```
**Response** (204 No Content)
---
### Users
#### Get Current User
```
GET /users/me
```
**Response** (200 OK)
```json
{
"id": "user_1",
"email": "user@example.com",
"name": "John Doe",
"settings": {
"darkMode": false,
"notifications": true,
"reminderTime": "09:00",
"defaultCategory": "cat_1",
"sortBy": "dueDate",
"sortOrder": "asc"
},
"createdAt": 1699000000000
}
```
#### Update Settings
```
PATCH /users/me/settings
```
**Request Body**
```json
{
"darkMode": true,
"notifications": false,
"reminderTime": "08:00",
"defaultCategory": "cat_2",
"sortBy": "priority",
"sortOrder": "desc"
}
```
**Response** (200 OK)
---
## Data Types
### Category
```typescript
interface Category {
id: string;
name: string;
color: string; // HEX format
order: number;
createdAt: number; // Unix timestamp (ms)
updatedAt: number; // Unix timestamp (ms)
}
```
### Task
```typescript
interface Task {
id: string;
title: string;
description: string;
categoryId: string;
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
completed: boolean;
dueDate: number; // Unix timestamp (ms), 0 if not set
dueTime: string; // HH:MM format, empty if not set
createdAt: number;
updatedAt: number;
subtasks?: Subtask[];
}
```
### Subtask
```typescript
interface Subtask {
id: string;
taskId: string;
title: string;
completed: boolean;
order: number;
createdAt: number;
updatedAt: number;
}
```
### UserSettings
```typescript
interface UserSettings {
darkMode: boolean;
notifications: boolean;
reminderTime: string; // HH:MM
defaultCategory: string; // Category ID
sortBy: 'dueDate' | 'priority' | 'title' | 'createdAt';
sortOrder: 'asc' | 'desc';
}
```
## Error Format
All error responses follow this format:
```json
{
"error": {
"code": "ERROR_CODE",
"message": "Human readable message",
"details": {}
}
}
```
Common error codes:
- `INVALID_PAYLOAD` - Request validation failed
- `UNAUTHORIZED` - Authentication required
- `FORBIDDEN` - Insufficient permissions
- `NOT_FOUND` - Resource not found
- `CONFLICT` - Version conflict
- `RATE_LIMITED` - Too many requests
- `SERVER_ERROR` - Internal server error
## Rate Limiting
- 100 requests/minute per user for sync endpoints
- 500 requests/minute per user for other endpoints
- Returns 429 with `Retry-After` header
## Webhooks (Optional)
Server can notify clients of remote changes:
```
POST /webhooks/sync
```
Payload:
```json
{
"event": "data_changed",
"entities": ["tasks", "categories"],
"timestamp": 1699150000000
}
```
Client should trigger sync on receipt.
+152
View File
@@ -0,0 +1,152 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Friends feature: friends service, FriendsProvider, FriendsModal, Settings entry
- Assignee field on tasks (schema v7, model, types, forms, sync, taskActions)
- AssigneeSelector component and assignee display in TaskItem
- QuickAddBar on tasks overview (copied from calendar)
- Removed WheelTimePicker selection band highlighter
- Removed drag lift popup from TaskItem (scale + shadow on drag)
- Removed checkbox from task items
- Fixed selection bar overlapping QuickAddBar on tasks overview
- Backend: mounted friends route, added assigneeId to task validation and sync
### Changed
- Due date prefilled from Calendar "+" button
- 12-hour time formatting across task rows and pickers
- Calendar strip scrolls to selected day when navigating months
- Calendar completion toggle now handles repeating tasks and reminders
- Task color strips now use the task's category color
### Fixed
- WheelTimePicker FlatList scroll clamping: added contentContainerStyle paddingBottom so all hours (0-23) and minutes (0-59) are reachable via scrolling
- WheelTimePicker: added selection highlight background to WheelRow, visually connecting selected item with colon separator
- WheelTimePicker: fixed number alignment in scroll list (row width 100%, contentContainerStyle stretch)
- Tasks without a due date no longer get overdue styling
- `useOverdueTasks` infinite re-subscription loop (Date.now in effect deps)
- Web/Android crashes from decorator transform ordering (babel config)
- FormButtons crash outside FormProvider; Cancel navigation fallback
- Task repeat settings, reminders and series info now sync to the backend
(previously repeated tasks lost their repeat after sync)
### Deprecated
- N/A
### Removed
- Task color picker (task color now comes from its category)
### Security
- N/A
---
## [1.0.0] - 2026-08-05
### Added
- **Core Features**
- To-Do List screen with category filter
- Add New Task screen with all fields
- Calendar screen with date strip
- Settings screen
- Bottom navigation bar
- **Task Management**
- Create, read, update, delete tasks
- Category organization (6 default categories)
- Priority levels (None, Low, Medium, High, Critical)
- Due dates and times
- Subtasks with unlimited nesting
- Descriptions (1000 chars)
- Task completion with visual feedback
- Overdue and due-today highlighting
- **Offline-First Architecture**
- WatermelonDB with SQLite persistence
- Reactive queries for instant UI updates
- Background sync with custom API
- Conflict resolution (last-write-wins)
- Automatic default category seeding
- **User Interface**
- Minimalist design with 8px grid
- Rounded containers (16-20px)
- Category color coding
- Smooth animations (150-250ms)
- Safe area support for notches
- Dark mode ready (toggle in settings)
- **Navigation**
- Expo Router file-based routing
- Persistent bottom tabs (Tasks, Calendar, Settings)
- Modal stack for Add Task screen
- Type-safe navigation
- **Developer Experience**
- TypeScript strict mode
- ESLint + Prettier configuration
- Path aliases (@/components, @/hooks, etc.)
- WatermelonDB decorators support
- React Hook Form + Zod validation
### Technical Details
#### Database Schema
- `categories` table (id, name, color, order, timestamps)
- `tasks` table (id, title, description, category_id, priority, completed, due_date, due_time, timestamps)
- `subtasks` table (id, task_id, title, completed, order, timestamps)
- Indexes on category_id, due_date, completed for performance
#### Sync Protocol
- Pull: GET /sync?since={timestamp}
- Push: POST /sync/push with batched changes
- Timestamp-based incremental sync
- Upsert semantics for conflict resolution
#### Components
- `Header` - App logo + title with rounded corners
- `CategoryFilter` - Horizontal scrollable chips
- `TaskList` - Virtualized FlatList with sections
- `TaskItem` - Swipeable row with checkbox, title, priority, due date, menu
- `FloatingActionButton` - Animated FAB with rotation
- `CategorySelector` - Chip group for task creation
- `DateTimePicker` - Native date/time pickers
- `PrioritySelector` - Segmented control with colors
- `SubtasksSection` - Dynamic field array
- `FormButtons` - Fixed bottom actions
#### Hooks
- `useDatabase` - Database context + initialization
- `useCategories` - Reactive category list
- `useTasks` - Filtered task queries with loading state
- `useTasksByDate` - Calendar date queries
---
## Version History Template
### [X.Y.Z] - YYYY-MM-DD
#### Added
- New features
#### Changed
- Changes in existing functionality
#### Deprecated
- Soon-to-be removed features
#### Removed
- Removed features
#### Fixed
- Bug fixes
#### Security
- Vulnerability fixes
+258
View File
@@ -0,0 +1,258 @@
# Quick Start Guide
## Prerequisites Checklist
- [ ] Node.js 20+ installed
- [ ] npm 10+ installed
- [ ] Expo CLI: `npm install -g expo-cli`
- [ ] EAS CLI: `npm install -g eas-cli` (for builds)
- [ ] Git installed
- [ ] iOS Simulator (Mac) or Android Studio for mobile testing
- [ ] VS Code recommended with extensions:
- Expo Tools
- TypeScript Hero
- React Native Tools
- Prettier
---
## 1. Clone & Install
```bash
# Clone repository
git clone <repository-url>
cd carry-your-live
# Install dependencies
cd carry-your-live
npm install
# Verify installation
npx expo-doctor
```
---
## 2. Environment Setup
Create `.env` file in project root:
```bash
# API Configuration
EXPO_PUBLIC_API_URL=http://localhost:3000/api
# Optional: Analytics, crash reporting
EXPO_PUBLIC_SENTRY_DSN=
EXPO_PUBLIC_AMPLITUDE_KEY=
```
---
## 3. Run Development Server
### Terminal 1: Start Metro Bundler
```bash
npx expo start
```
### Terminal 2: Run on Platform
```bash
# iOS Simulator (Mac only)
npx expo start --ios
# Android Emulator
npx expo start --android
# Web Browser
npx expo start --web
# Physical device (scan QR code with Expo Go)
npx expo start --tunnel
```
---
## 4. Project Structure Quick Reference
```
carry-your-live/
├── app/ # Expo Router screens
│ ├── _layout.tsx # Root layout + providers
│ ├── (tabs)/ # Tab screens
│ │ ├── index.tsx # Tasks list
│ │ ├── calendar.tsx # Calendar view
│ │ └── settings.tsx # Settings
│ └── add-task.tsx # Add/edit task modal
├── src/
│ ├── components/ # Reusable UI
│ ├── database/ # WatermelonDB setup
│ ├── hooks/ # React hooks
│ ├── models/ # Database models
│ ├── constants/ # Config values
│ └── types/ # TypeScript types
```
---
## 5. Key Commands
```bash
# Development
npx expo start # Start dev server
npx expo start -c # Clear cache & start
npx expo start --ios # iOS simulator
npx expo start --android # Android emulator
npx expo start --web # Web browser
# Code Quality
npx tsc --noEmit # Type check
npx expo lint # Lint
npm run format # Format with Prettier
# Database
npx expo run:ios # Build & run native iOS
npx expo run:android # Build & run native Android
# Build
eas build --platform ios # iOS build
eas build --platform android # Android build
eas build --platform web # Web build
# Database inspection (development)
# Open in browser: chrome://inspect/#devices
# Or use React Native Debugger
```
---
## 6. Common Development Tasks
### Add a New Screen
1. Create file in `app/` (e.g., `app/profile.tsx`)
2. Export default component
3. Add to navigation in `app/(tabs)/_layout.tsx` or as modal in `app/_layout.tsx`
### Add a Database Field
1. Update schema in `src/database/schema.ts`
2. Update model in `src/models/`
3. Create migration in `src/database/migrations.ts`
4. Update forms/components to use new field
### Add a Category
Edit `src/constants/index.ts`:
```typescript
export const DEFAULT_CATEGORIES = [
// ... existing
{ name: 'Travel', color: '#00BCD4', order: 6 },
];
```
### Modify Sync Logic
Edit `src/database/sync.ts`:
- `pullChanges()` - How to fetch from server
- `pushChanges()` - How to send to server
- Conflict resolution strategy
---
## 7. Debugging Tips
### Inspect Database
```typescript
// In any component
import { useDatabase } from '@/hooks/useDatabase';
const { database } = useDatabase();
// database.collections.get('tasks').query().fetch().then(console.log)
```
### Network Requests
- Use React Native Debugger
- Or flipper with `react-native-flipper`
- Enable `console.log` for sync operations
### TypeScript Errors
```bash
# Full type check
npx tsc --noEmit
# Check specific file
npx tsc --noEmit src/components/TaskItem.tsx
```
---
## 8. Testing Checklist
### Before Commit
- [ ] `npx tsc --noEmit` passes
- [ ] `npx expo lint` passes
- [ ] App runs on iOS simulator
- [ ] App runs on Android emulator
- [ ] App runs on web
- [ ] Offline mode works (disable network)
- [ ] Sync works (with mock server)
### Manual Testing Flow
1. **Add Task**: Open app → FAB → Fill form → Submit → Verify in list
2. **Category Filter**: Tap categories → Verify filtering
3. **Complete Task**: Tap checkbox → Verify strikethrough + move to completed
4. **Delete Task**: Swipe left → Confirm → Verify removal
5. **Subtasks**: Add task with subtasks → Verify rendering
6. **Calendar**: Navigate to calendar → Select date → Verify tasks
7. **Settings**: Toggle dark mode → Verify persistence
8. **Offline**: Airplane mode → Add/edit tasks → Re-enable → Verify sync
---
## 9. Troubleshooting
| Issue | Solution |
|-------|----------|
| `npx expo start` fails | `rm -rf node_modules && npm install` |
| Metro bundler issues | `npx expo start -c` |
| iOS build fails | `cd ios && pod install && cd ..` |
| Android build fails | `cd android && ./gradlew clean && cd ..` |
| TypeScript errors | Check `tsconfig.json` extends `expo/tsconfig.base` |
| Database not persisting | Verify `expo-sqlite` installed, check `DatabaseProvider` wraps app |
| Sync not working | Check API URL in `.env`, verify server running |
| Animations laggy | Enable `useNativeDriver: true` where possible |
---
## 10. Useful Resources
- [Expo Documentation](https://docs.expo.dev/)
- [WatermelonDB Docs](https://nozbe.github.io/WatermelonDB/)
- [React Hook Form](https://react-hook-form.com/)
- [Expo Router](https://expo.github.io/router/)
- [Reanimated 3](https://docs.swmansion.com/react-native-reanimated/)
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
---
## 11. Project Scripts (package.json)
```json
{
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
"db:studio": "npx expo run:ios --configuration Debug",
"build:ios": "eas build --platform ios",
"build:android": "eas build --platform android",
"build:web": "eas build --platform web",
"submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android"
}
}
```
+311
View File
@@ -0,0 +1,311 @@
# Carry Your Live - Mobile To-Do & Calendar App
A minimalist, offline-first task management app built with Expo, React Native, and WatermelonDB. Features three primary screens: To-Do List, Add New Task, and Calendar with bottom navigation.
## Features
### Core Functionality
- **Offline-First Architecture**: Full CRUD operations work offline with WatermelonDB + SQLite
- **Auto-Sync**: Background synchronization with custom Postgres API when online
- **Cross-Platform**: iOS, Android, and Web from single codebase
### Screens
#### 1. To-Do List Screen
- **Header**: App logo + "TODO" title with rounded corners
- **Category Filter**: Horizontal scrollable chips (Work, Personal, School, Shopping, Health, Finance, All)
- Single selection with visual feedback (thicker border, shadow, scale animation)
- Instant task filtering
- **Task List**: Vertical scrolling with:
- Checkbox (empty/checked states with animation)
- Task title (truncated with ellipsis)
- Three-dot overflow menu (Edit, Delete, Duplicate, Mark Complete, Change Category, Change Priority)
- Priority badges with colors
- Due date/time with overdue/due-today highlighting
- Completed section (collapsible)
- **Floating Action Button**: Bottom-right, opens Add Task screen with press animation
#### 2. Add New Task Screen
- **Category Selector**: Horizontal row of colored circles (required)
- **Task Name**: Single-line input, 100 char max (required)
- **Subtasks**: Unlimited, add/remove individually
- **Date & Time**: Date picker, time picker, calendar button
- **Priority**: Dropdown (None, Low, Medium, High, Critical) with color indicators
- **Description**: Multi-line text area, 1000 char max
- **Bottom Actions**: Submit (primary) / Cancel (secondary) fixed buttons
#### 3. Calendar Screen
- **Date Strip**: Horizontal scrollable days of current month
- **Month Label**: Centered (e.g., "JAN 2026")
- **Task List**: Tasks for selected day with category color, title, time, completion status
- **Empty State**: "No tasks scheduled."
#### 4. Settings Screen
- Dark Mode toggle
- Push Notifications toggle
- Reminder Preferences
- Default Category selector
- Sort Tasks By (Date, Priority, Alphabetically, Creation Date)
- Backup & Sync
- Version, Privacy Policy, Terms of Service
### Task States
- **Pending**: Empty checkbox, normal text
- **Completed**: Checked checkbox, strikethrough, reduced opacity
- **Overdue**: Red due date, warning indicator
- **Due Today**: Blue highlight
- **High Priority**: Priority badge
### Interactions
- Tap task → Edit screen
- Long press → Selection mode (multi-delete, multi-complete, change category/priority)
- Swipe left → Delete
- Swipe right → Complete
- Pull down → Refresh
## Tech Stack
| Layer | Technology |
|-------|------------|
| Framework | Expo (React Native) |
| Language | TypeScript |
| Navigation | Expo Router (file-based) |
| Database | WatermelonDB + SQLite |
| State | WatermelonDB (reactive queries, no Redux needed) |
| Forms | React Hook Form + Zod validation |
| UI Components | React Native Paper + Custom SVG icons |
| Animations | React Native Reanimated |
| Date/Time | @react-native-community/datetimepicker + date-fns |
| Build | Expo Dev Client / EAS Build |
## Project Structure
```
carry-your-live/
├── app/
│ ├── _layout.tsx # Root layout + DatabaseProvider
│ ├── (tabs)/
│ │ ├── _layout.tsx # Bottom tab navigator
│ │ ├── index.tsx # Tasks screen
│ │ ├── calendar.tsx # Calendar screen
│ │ └── settings.tsx # Settings screen
│ └── add-task.tsx # Add Task screen
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── Header.tsx
│ │ ├── CategoryFilter.tsx
│ │ ├── TaskList.tsx
│ │ ├── TaskItem.tsx
│ │ ├── FloatingActionButton.tsx
│ │ ├── TabBarIcon.tsx
│ │ ├── CategorySelector.tsx
│ │ ├── TaskNameInput.tsx
│ │ ├── SubtasksSection.tsx
│ │ ├── DateTimePicker.tsx
│ │ ├── PrioritySelector.tsx
│ │ ├── DescriptionInput.tsx
│ │ ├── FormButtons.tsx
│ │ └── ListItem.tsx
│ ├── database/
│ │ ├── schema.ts # WatermelonDB schema
│ │ ├── index.ts # Database instance + collections
│ │ └── sync.ts # Custom API sync adapter
│ ├── models/
│ │ ├── Task.ts
│ │ ├── Category.ts
│ │ └── Subtask.ts
│ ├── hooks/
│ │ ├── useDatabase.tsx # Database context & providers
│ │ └── useTasks.tsx # Task queries with reactive state
│ ├── constants/
│ │ └── index.ts # Categories, priorities, colors
│ └── types/
│ └── index.ts # TypeScript interfaces
```
## Data Models
### Task
```typescript
{
id: string;
title: string;
description: string;
categoryId: string;
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
completed: boolean;
dueDate: number; // timestamp
dueTime: string; // "HH:MM"
createdAt: Date;
updatedAt: Date;
subtasks: Subtask[];
}
```
### Category
```typescript
{
id: string;
name: string;
color: '#HEX';
order: number;
}
```
### Subtask
```typescript
{
id: string;
taskId: string;
title: string;
completed: boolean;
order: number;
createdAt: Date;
updatedAt: Date;
}
```
## Default Categories
| Name | Color | Order |
|------|-------|-------|
| Work | #E53935 (Red) | 0 |
| Personal | #43A047 (Green) | 1 |
| School | #1E88E5 (Blue) | 2 |
| Shopping | #FB8C00 (Orange) | 3 |
| Health | #8E24AA (Purple) | 4 |
| Finance | #FDD835 (Yellow) | 5 |
## Priority Colors
| Priority | Color |
|----------|-------|
| None | #9E9E9E |
| Low | #43A047 |
| Medium | #FB8C00 |
| High | #E53935 |
| Critical | #B71C1C |
## Getting Started
### Prerequisites
- Node.js 20+
- npm or yarn
- Expo CLI (`npm install -g expo-cli`)
- iOS Simulator (Mac) / Android Studio for mobile testing
### Installation
```bash
cd carry-your-live
npm install
```
### Development
```bash
# Start Expo dev server
npx expo start
# Run on specific platform
npx expo start --ios
npx expo start --android
npx expo start --web
```
### Building
```bash
# Install EAS CLI
npm install -g eas-cli
# Configure project
eas build:configure
# Build for platforms
eas build --platform ios
eas build --platform android
eas build --platform web
```
## Sync API Specification
The app expects a custom API at `http://localhost:3000/api` (configurable in `src/database/sync.ts`):
### Pull Changes
```
GET /sync?since={timestamp}
```
Response:
```json
{
"categories": [{ "id", "name", "color", "order", "createdAt", "updatedAt" }],
"tasks": [{ "id", "title", "description", "categoryId", "priority", "completed", "dueDate", "dueTime", "createdAt", "updatedAt" }],
"subtasks": [{ "id", "taskId", "title", "completed", "order", "createdAt", "updatedAt" }],
"timestamp": 1234567890
}
```
### Push Changes
```
POST /sync/push
```
Body:
```json
{
"changes": {
"categories": [...],
"tasks": [...],
"subtasks": [...]
},
"lastPulledAt": 1234567890
}
```
## Configuration
### Environment Variables
Create `.env` file:
```
EXPO_PUBLIC_API_URL=http://your-api-url/api
```
### App Config
Modify `app.json` for:
- App name, slug, version
- Icon, splash screen
- Permissions (notifications, etc.)
- Orientation, theme
## Visual Style Guide
- **Background**: White (#FFFFFF)
- **Primary Accent**: #E53935 (Pink/Red)
- **Typography**: Black (#212121) primary, #757575 secondary, #9E9E9E tertiary
- **Borders**: Thin (#E0E0E0), selected #E53935
- **Radius**: 16-20px for cards/buttons/inputs
- **Spacing**: 8px grid system
- **Shadows**: Minimal (elevation 1-3)
- **Animations**: 150-250ms, cubic easing
## Offline-First Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Expo App │────▶│ WatermelonDB │────▶│ Your API │
│ (iOS/Android│ │ (SQLite) │ │ (Postgres) │
│ Web) │◀───▶│ + Sync │◀───▶│ │
└─────────────┘ └──────────────┘ └─────────────┘
```
1. All writes go to local SQLite first
2. Reactive queries update UI instantly
3. Background sync pushes/pulls changes
4. Conflict resolution: last-write-wins (customizable)
## Contributing
1. Follow existing code conventions
2. Use TypeScript strict mode
3. Write components with React Hook Form + Zod
4. Use WatermelonDB decorators for models
5. Test offline scenarios
## License
MIT
+314
View File
@@ -0,0 +1,314 @@
# Technical Documentation
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Expo App Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Tasks │ │ Calendar │ │ Settings │ │
│ │ Screen │ │ Screen │ │ Screen │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ React Hook Form + Zod │ │
│ │ (Form Validation) │ │
│ └─────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Database Layer │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ WatermelonDB Database │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Categories│ │ Tasks │ │ Subtasks │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SQLite Adapter (expo-sqlite) │ │
│ └─────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Sync Layer │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Custom Sync Adapter │ │
│ │ • pullChanges() - GET /sync?since={timestamp} │ │
│ │ • pushChanges() - POST /sync/push │ │
│ │ • Conflict resolution (last-write-wins) │ │
│ └─────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend API │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Custom Postgres API │ │
│ │ • REST or GraphQL endpoints │ │
│ │ • Authentication (JWT/OAuth) │ │
│ │ • Conflict detection & resolution │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Database Schema
### Tables
```sql
-- Categories table
CREATE TABLE categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Tasks table
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category_id TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'none',
completed INTEGER NOT NULL DEFAULT 0,
due_date INTEGER NOT NULL DEFAULT 0,
due_time TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (category_id) REFERENCES categories(id)
);
CREATE INDEX idx_tasks_category ON tasks(category_id);
CREATE INDEX idx_tasks_due_date ON tasks(due_date);
CREATE INDEX idx_tasks_completed ON tasks(completed);
-- Subtasks table
CREATE TABLE subtasks (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
"order" INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id)
);
CREATE INDEX idx_subtasks_task ON subtasks(task_id);
```
## Key Components
### DatabaseProvider (`src/hooks/useDatabase.tsx`)
Central context provider managing:
- Database initialization
- Default category seeding
- Collection access
- Ready state
```typescript
interface DatabaseContextType {
database: Database;
collections: {
categories: Collection<Category>;
tasks: Collection<Task>;
subtasks: Collection<Subtask>;
};
isReady: boolean;
initializeDatabase: () => Promise<void>;
}
```
### useTasks Hook (`src/hooks/useTasks.tsx`)
Reactive task queries with loading states:
```typescript
function useTasks(categoryId?: string, showCompleted = false): {
tasks: Task[];
loading: boolean;
}
```
### WatermelonDB Models
All models use decorators for field mapping:
```typescript
@field('column_name') propertyName!: Type;
@date('column_name') dateProperty!: Date;
@children('table_name') relationName!: Query<Model>;
```
## Sync Implementation
### Pull Strategy
1. Client sends `lastPulledAt` timestamp
2. Server returns all changes since timestamp
3. Client applies changes in transaction:
- Upsert categories (by id)
- Upsert tasks (by id)
- Upsert subtasks (by id)
### Push Strategy
1. Client collects local changes (created/updated/deleted)
2. Sends batched changes with `lastPulledAt`
3. Server applies and returns new timestamp
### Conflict Resolution
Default: Last-write-wins based on `updatedAt`
Customizable in `src/database/sync.ts`
## Navigation Structure
```
app/
├── _layout.tsx # Stack navigator + DatabaseProvider
│ ├── (tabs)/ # Tab group
│ │ ├── _layout.tsx # Tabs navigator
│ │ ├── index.tsx # / (Tasks)
│ │ ├── calendar.tsx # /calendar
│ │ └── settings.tsx # /settings
│ └── add-task.tsx # /add-task (modal push)
```
### Navigation Patterns
- **Tabs**: Persistent bottom navigation
- **Add Task**: Push onto stack (modal on iOS)
- **Edit Task**: Push onto stack (future)
## Form Handling
### Add Task Form (`app/add-task.tsx`)
Uses `react-hook-form` with `zod` resolver:
```typescript
const schema = z.object({
title: z.string().min(1).max(100),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
subtasks: z.array(z.object({ title: z.string() })).optional(),
});
```
### Field Components
Each field is a controlled component using `Controller`:
- `CategorySelector` - Horizontal chip selection
- `TaskNameInput` - Text input with validation
- `SubtasksSection` - FieldArray for dynamic subtasks
- `DateTimePicker` - Native date/time pickers
- `PrioritySelector` - Segmented control
- `DescriptionInput` - Multiline text area
## Animations
### Reanimated 3 Patterns
1. **FAB Rotation** (`FloatingActionButton.tsx`)
```typescript
const rotateAnim = useSharedValue(0);
rotateAnim.value = (rotateAnim.value + 0.25) % 1;
```
2. **Category Selection** (`CategoryFilter.tsx`)
```typescript
const scaleAnim = useSharedValue(1);
scaleAnim.value = withTiming(selected ? 1.05 : 1);
```
3. **Checkbox Toggle** (`TaskItem.tsx`)
```typescript
const checkedAnim = useSharedValue(0);
checkedAnim.value = withTiming(task.completed ? 1 : 0);
```
## Platform-Specific Considerations
### iOS
- DateTimePicker: `display="spinner"`
- Keyboard: `behavior="padding"`
- Safe areas: Automatic via `react-native-safe-area-context`
### Android
- DateTimePicker: `display="default"`
- Keyboard: `behavior="height"`
- Elevation shadows for Material Design
### Web
- React Native Web for DOM rendering
- DateTimePicker falls back to HTML inputs
- No native SQLite (uses IndexedDB via expo-sqlite web polyfill)
## Performance Optimizations
1. **Lazy Loading**: Components loaded on demand
2. **Memoization**: `React.memo` for list items
3. **Virtualized Lists**: FlatList with `windowSize`
4. **Query Optimization**: Indexed columns for common queries
5. **Batch Writes**: Database transactions for multiple operations
## Testing Strategy
### Unit Tests
- Hooks: `useTasks`, `useCategories`
- Validators: Zod schemas
- Utilities: Date formatting, priority sorting
### Integration Tests
- Database CRUD operations
- Sync adapter with mock server
- Form submission flow
### E2E Tests
- Critical user flows (add task, complete, filter)
- Offline/online transitions
- Cross-platform consistency
## Deployment Checklist
- [ ] Configure `app.json` with production values
- [ ] Set `EXPO_PUBLIC_API_URL` environment variable
- [ ] Enable EAS Build for iOS/Android
- [ ] Configure code signing certificates
- [ ] Set up CI/CD pipeline
- [ ] Test offline sync scenarios
- [ ] Verify push notifications (if enabled)
- [ ] Performance profiling on device
## Troubleshooting
### Common Issues
**Database not initializing**
- Check `expo-sqlite` installation
- Verify `DatabaseProvider` wraps app root
- Check Metro bundler cache: `npx expo start -c`
**Sync failing**
- Verify API endpoint accessibility
- Check CORS headers on server
- Inspect network requests in React Native Debugger
**TypeScript errors with WatermelonDB**
- Ensure `experimentalDecorators: true` in tsconfig
- Check `@nozbe/watermelondb` version compatibility
- Run `npx expo-doctor` for dependency issues
**Animation not working**
- Verify `react-native-reanimated` babel plugin
- Check `useNativeDriver: true` for supported properties
- Ensure worklet functions are properly extracted
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
.git
*.log
+12
View File
@@ -0,0 +1,12 @@
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/carry_your_live
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production-min-32-chars
# Server
PORT=3000
NODE_ENV=development
# Frontend URL (for CORS)
FRONTEND_URL=http://localhost:8081
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-alpine AS base
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
# Build
RUN npm run build
EXPOSE 3000
FROM base AS dev
CMD ["npm", "run", "dev"]
FROM base AS prod
CMD ["npm", "start"]
+143
View File
@@ -0,0 +1,143 @@
# Carry Your Live - Backend API
REST API for the Carry Your Live task management app with offline-first sync support.
## Tech Stack
- **Runtime**: Node.js 20+ with TypeScript
- **Framework**: Express.js
- **Database**: PostgreSQL with Drizzle ORM
- **Authentication**: JWT (JSON Web Tokens)
- **Validation**: Zod
## Quick Start
### Option A — Docker (Postgres + API in containers)
Requires Docker. Runs the database **and** the Node API together:
```bash
docker compose up --build
```
This starts:
- `carry-your-live-db` — PostgreSQL 16 on port 5432
- `carry-your-live-api` — the API in dev mode (tsx watch, hot reload) on port 3000, schema auto-applied on start
Server runs at `http://localhost:3000` (health check: `GET /health`).
For production-style serving of the built app:
```bash
# Build and run the slim prod image instead
docker build --target prod -t carry-your-live-api:prod .
docker run --rm -p 3000:3000 --env-file .env carry-your-live-api:prod
```
### Option B — Local Node + Docker Postgres
### Prerequisites
- Node.js 20+
- PostgreSQL 16+ (or use Docker)
### Setup
1. **Start PostgreSQL** (using Docker):
```bash
docker-compose up -d postgres
```
2. **Install dependencies**:
```bash
npm install
```
3. **Configure environment**:
```bash
cp .env.example .env
# Edit .env with your settings
```
4. **Run database migrations**:
```bash
npm run db:push
```
5. **Start development server**:
```bash
npm run dev
```
Server runs at `http://localhost:3000`
## API Endpoints
### Authentication
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login
- `GET /api/auth/me` - Get current user
### Categories
- `GET /api/categories` - List categories
- `POST /api/categories` - Create category
- `PATCH /api/categories/:id` - Update category
- `DELETE /api/categories/:id` - Delete category
### Tasks
- `GET /api/tasks` - List tasks (with filters)
- `GET /api/tasks/:id` - Get task with subtasks
- `POST /api/tasks` - Create task
- `PATCH /api/tasks/:id` - Update task
- `DELETE /api/tasks/:id` - Delete task
- `POST /api/tasks/batch` - Batch operations
### Subtasks
- `GET /api/subtasks/task/:taskId` - List subtasks for task
- `POST /api/subtasks/task/:taskId` - Create subtask
- `PATCH /api/subtasks/:id` - Update subtask
- `DELETE /api/subtasks/:id` - Delete subtask
### Users
- `GET /api/users/me` - Get user with settings
- `PATCH /api/users/me/settings` - Update settings
### Sync (Offline-first)
- `GET /api/sync?since=<timestamp>` - Pull changes since timestamp
- `POST /api/sync/push` - Push local changes
## Database Schema
See `src/db/schema.ts` for Drizzle schema definitions.
## Sync Protocol
The sync endpoint uses a cursor-based approach:
1. **Pull**: Client sends `since` timestamp, server returns all changes since then
2. **Push**: Client sends batched changes with `lastPulledAt`, server applies with conflict resolution (last-write-wins)
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | Required |
| `JWT_SECRET` | Secret for JWT signing | Required (32+ chars) |
| `PORT` | Server port | 3000 |
| `NODE_ENV` | Environment | development |
| `FRONTEND_URL` | CORS origin | http://localhost:8081 |
## Production Deployment
1. Set `NODE_ENV=production`
2. Use strong `JWT_SECRET` (32+ random chars)
3. Configure proper `DATABASE_URL`
4. Run `npm run build` then `npm start`
5. Use process manager (PM2, systemd) or container orchestration
The included `Dockerfile` has a `prod` stage that builds TypeScript and serves `dist/`
with production-only dependencies on a minimal `node:20-alpine` image.
## License
MIT
+4
View File
@@ -0,0 +1,4 @@
import * as schema from './schema';
export declare const db: import("drizzle-orm/node-postgres").NodePgDatabase<typeof schema>;
export type DB = typeof db;
//# sourceMappingURL=index.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/db/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,MAAM,UAAU,CAAC;AAOnC,eAAO,MAAM,EAAE,mEAA4B,CAAC;AAE5C,MAAM,MAAM,EAAE,GAAG,OAAO,EAAE,CAAC"}
+45
View File
@@ -0,0 +1,45 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.db = void 0;
const node_postgres_1 = require("drizzle-orm/node-postgres");
const pg_1 = require("pg");
const schema = __importStar(require("./schema"));
const pool = new pg_1.Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
});
exports.db = (0, node_postgres_1.drizzle)(pool, { schema });
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/db/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,6DAAoD;AACpD,2BAA0B;AAC1B,iDAAmC;AAEnC,MAAM,IAAI,GAAG,IAAI,SAAI,CAAC;IACpB,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY;IAC1C,GAAG,EAAE,EAAE;CACR,CAAC,CAAC;AAEU,QAAA,EAAE,GAAG,IAAA,uBAAO,EAAC,IAAI,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC"}
+994
View File
@@ -0,0 +1,994 @@
export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "users";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "users";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
username: import("drizzle-orm/pg-core").PgColumn<{
name: "username";
tableName: "users";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
passwordHash: import("drizzle-orm/pg-core").PgColumn<{
name: "password_hash";
tableName: "users";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "users";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const friendships: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "friendships";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "friendships";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "friendships";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
friendId: import("drizzle-orm/pg-core").PgColumn<{
name: "friend_id";
tableName: "friendships";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
status: import("drizzle-orm/pg-core").PgColumn<{
name: "status";
tableName: "friendships";
dataType: "string";
columnType: "PgText";
data: "pending" | "accepted";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["pending", "accepted"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "friendships";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "friendships";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const categories: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "categories";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "categories";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "categories";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
name: import("drizzle-orm/pg-core").PgColumn<{
name: "name";
tableName: "categories";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
color: import("drizzle-orm/pg-core").PgColumn<{
name: "color";
tableName: "categories";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
order: import("drizzle-orm/pg-core").PgColumn<{
name: "order";
tableName: "categories";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "categories";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "categories";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "tasks";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
categoryId: import("drizzle-orm/pg-core").PgColumn<{
name: "category_id";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
title: import("drizzle-orm/pg-core").PgColumn<{
name: "title";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
description: import("drizzle-orm/pg-core").PgColumn<{
name: "description";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
priority: import("drizzle-orm/pg-core").PgColumn<{
name: "priority";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: "none" | "low" | "medium" | "high" | "critical";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "low", "medium", "high", "critical"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
completed: import("drizzle-orm/pg-core").PgColumn<{
name: "completed";
tableName: "tasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
dueDate: import("drizzle-orm/pg-core").PgColumn<{
name: "due_date";
tableName: "tasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
dueTime: import("drizzle-orm/pg-core").PgColumn<{
name: "due_time";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
endTime: import("drizzle-orm/pg-core").PgColumn<{
name: "end_time";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeat: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: "custom" | "none" | "daily" | "weekly" | "monthly";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "daily", "weekly", "monthly", "custom"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeatInterval: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat_interval";
tableName: "tasks";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeatDays: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat_days";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
seriesId: import("drizzle-orm/pg-core").PgColumn<{
name: "series_id";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
assigneeId: import("drizzle-orm/pg-core").PgColumn<{
name: "assignee_id";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
reminder: import("drizzle-orm/pg-core").PgColumn<{
name: "reminder";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "at_time", "15", "30", "60", "120", "1440"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "tasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "tasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "repeat_profiles";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "repeat_profiles";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "repeat_profiles";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
name: import("drizzle-orm/pg-core").PgColumn<{
name: "name";
tableName: "repeat_profiles";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeat: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat";
tableName: "repeat_profiles";
dataType: "string";
columnType: "PgText";
data: "custom" | "none" | "daily" | "weekly" | "monthly";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "daily", "weekly", "monthly", "custom"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeatInterval: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat_interval";
tableName: "repeat_profiles";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeatDays: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat_days";
tableName: "repeat_profiles";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "repeat_profiles";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "repeat_profiles";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const subtasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "subtasks";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
taskId: import("drizzle-orm/pg-core").PgColumn<{
name: "task_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
title: import("drizzle-orm/pg-core").PgColumn<{
name: "title";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
completed: import("drizzle-orm/pg-core").PgColumn<{
name: "completed";
tableName: "subtasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
order: import("drizzle-orm/pg-core").PgColumn<{
name: "order";
tableName: "subtasks";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const userSettings: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "user_settings";
schema: undefined;
columns: {
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "user_settings";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
darkMode: import("drizzle-orm/pg-core").PgColumn<{
name: "dark_mode";
tableName: "user_settings";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
notifications: import("drizzle-orm/pg-core").PgColumn<{
name: "notifications";
tableName: "user_settings";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
reminderTime: import("drizzle-orm/pg-core").PgColumn<{
name: "reminder_time";
tableName: "user_settings";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
defaultCategory: import("drizzle-orm/pg-core").PgColumn<{
name: "default_category";
tableName: "user_settings";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
sortBy: import("drizzle-orm/pg-core").PgColumn<{
name: "sort_by";
tableName: "user_settings";
dataType: "string";
columnType: "PgText";
data: "createdAt" | "title" | "priority" | "dueDate";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["dueDate", "priority", "title", "createdAt"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
sortOrder: import("drizzle-orm/pg-core").PgColumn<{
name: "sort_order";
tableName: "user_settings";
dataType: "string";
columnType: "PgText";
data: "asc" | "desc";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["asc", "desc"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "user_settings";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
//# sourceMappingURL=schema.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKhB,CAAC;AAEH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAevB,CAAC;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQrB,CAAC;AAEH,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmBhB,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASzB,CAAC;AAEH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASnB,CAAC;AAEH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASvB,CAAC"}
+83
View File
@@ -0,0 +1,83 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.userSettings = exports.subtasks = exports.repeatProfiles = exports.tasks = exports.categories = exports.friendships = exports.users = void 0;
const pg_core_1 = require("drizzle-orm/pg-core");
const drizzle_orm_1 = require("drizzle-orm");
exports.users = (0, pg_core_1.pgTable)('users', {
id: (0, pg_core_1.text)('id').primaryKey(),
username: (0, pg_core_1.text)('username').notNull().unique(),
passwordHash: (0, pg_core_1.text)('password_hash').notNull(),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
});
exports.friendships = (0, pg_core_1.pgTable)('friendships', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
friendId: (0, pg_core_1.text)('friend_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
status: (0, pg_core_1.text)('status', { enum: ['pending', 'accepted'] }).notNull().default('pending'),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}, (t) => ({
pairUnique: (0, pg_core_1.unique)('friendships_pair').on(t.userId, t.friendId),
userIdx: (0, pg_core_1.index)('friendships_user_idx').on(t.userId),
friendIdx: (0, pg_core_1.index)('friendships_friend_idx').on(t.friendId),
}));
exports.categories = (0, pg_core_1.pgTable)('categories', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
name: (0, pg_core_1.text)('name').notNull(),
color: (0, pg_core_1.text)('color').notNull(),
order: (0, pg_core_1.integer)('order').notNull().default(0),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
});
exports.tasks = (0, pg_core_1.pgTable)('tasks', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
categoryId: (0, pg_core_1.text)('category_id').notNull().references(() => exports.categories.id, { onDelete: 'cascade' }),
title: (0, pg_core_1.text)('title').notNull(),
description: (0, pg_core_1.text)('description').notNull().default(''),
priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
completed: (0, pg_core_1.boolean)('completed').notNull().default(false),
dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0),
dueTime: (0, pg_core_1.text)('due_time').notNull().default(''),
endTime: (0, pg_core_1.text)('end_time').notNull().default(''),
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
seriesId: (0, pg_core_1.text)('series_id').notNull().default(''),
assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }),
reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
});
exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
name: (0, pg_core_1.text)('name').notNull(),
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
});
exports.subtasks = (0, pg_core_1.pgTable)('subtasks', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
taskId: (0, pg_core_1.text)('task_id').notNull().references(() => exports.tasks.id, { onDelete: 'cascade' }),
title: (0, pg_core_1.text)('title').notNull(),
completed: (0, pg_core_1.boolean)('completed').notNull().default(false),
order: (0, pg_core_1.integer)('order').notNull().default(0),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
});
exports.userSettings = (0, pg_core_1.pgTable)('user_settings', {
userId: (0, pg_core_1.text)('user_id').primaryKey().references(() => exports.users.id, { onDelete: 'cascade' }),
darkMode: (0, pg_core_1.boolean)('dark_mode').notNull().default(false),
notifications: (0, pg_core_1.boolean)('notifications').notNull().default(true),
reminderTime: (0, pg_core_1.text)('reminder_time').notNull().default('09:00'),
defaultCategory: (0, pg_core_1.text)('default_category'),
sortBy: (0, pg_core_1.text)('sort_by', { enum: ['dueDate', 'priority', 'title', 'createdAt'] }).notNull().default('dueDate'),
sortOrder: (0, pg_core_1.text)('sort_order', { enum: ['asc', 'desc'] }).notNull().default('asc'),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
});
//# sourceMappingURL=schema.js.map
+1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import 'dotenv/config';
declare const app: import("express-serve-static-core").Express;
export default app;
//# sourceMappingURL=index.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAYvB,QAAA,MAAM,GAAG,6CAAY,CAAC;AAkCtB,eAAe,GAAG,CAAC"}
+45
View File
@@ -0,0 +1,45 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
require("dotenv/config");
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const auth_1 = __importDefault(require("./routes/auth"));
const categories_1 = __importDefault(require("./routes/categories"));
const tasks_1 = __importDefault(require("./routes/tasks"));
const subtasks_1 = __importDefault(require("./routes/subtasks"));
const users_1 = __importDefault(require("./routes/users"));
const friends_1 = __importDefault(require("./routes/friends"));
const sync_1 = __importDefault(require("./routes/sync"));
const errorHandler_1 = require("./middleware/errorHandler");
const app = (0, express_1.default)();
const PORT = process.env.PORT || 3000;
app.use((0, cors_1.default)({
origin: true,
credentials: true,
}));
app.use(express_1.default.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
// API routes
app.use('/api/auth', auth_1.default);
app.use('/api/categories', categories_1.default);
app.use('/api/tasks', tasks_1.default);
app.use('/api/subtasks', subtasks_1.default);
app.use('/api/users', users_1.default);
app.use('/api/sync', sync_1.default);
app.use('/api/friends', friends_1.default);
// 404 handler
app.use(errorHandler_1.notFoundHandler);
// Error handler
app.use(errorHandler_1.errorHandler);
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
console.log(`📚 API available at http://localhost:${PORT}/api`);
});
exports.default = app;
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,yBAAuB;AACvB,sDAA8B;AAC9B,gDAAwB;AACxB,yDAAuC;AACvC,qEAAiD;AACjD,2DAAwC;AACxC,iEAA8C;AAC9C,2DAAwC;AACxC,+DAA4C;AAC5C,yDAAuC;AACvC,4DAA0E;AAE1E,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,EAAC;IACX,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;CAClB,CAAC,CAAC,CAAC;AACJ,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,oBAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,kBAAa,CAAC,CAAC;AACxC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAY,CAAC,CAAC;AAEtC,cAAc;AACd,GAAG,CAAC,GAAG,CAAC,8BAAe,CAAC,CAAC;AAEzB,gBAAgB;AAChB,GAAG,CAAC,GAAG,CAAC,2BAAY,CAAC,CAAC;AAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yCAAyC,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,wCAAwC,IAAI,MAAM,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,kBAAe,GAAG,CAAC"}
+11
View File
@@ -0,0 +1,11 @@
import { Request, Response, NextFunction } from 'express';
export declare class AppError extends Error {
code: string;
message: string;
statusCode: number;
details?: Record<string, any> | undefined;
constructor(code: string, message: string, statusCode?: number, details?: Record<string, any> | undefined);
}
export declare function errorHandler(err: Error, req: Request, res: Response, next: NextFunction): void;
export declare function notFoundHandler(req: Request, res: Response): void;
//# sourceMappingURL=errorHandler.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"errorHandler.d.ts","sourceRoot":"","sources":["../../src/middleware/errorHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG1D,qBAAa,QAAS,SAAQ,KAAK;IAExB,IAAI,EAAE,MAAM;IACZ,OAAO,EAAE,MAAM;IACf,UAAU,EAAE,MAAM;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;gBAH7B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,UAAU,GAAE,MAAY,EACxB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,YAAA;CAKvC;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CA+B9F;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,IAAI,CAOjE"}
+59
View File
@@ -0,0 +1,59 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AppError = void 0;
exports.errorHandler = errorHandler;
exports.notFoundHandler = notFoundHandler;
const zod_1 = require("zod");
class AppError extends Error {
code;
message;
statusCode;
details;
constructor(code, message, statusCode = 500, details) {
super(message);
this.code = code;
this.message = message;
this.statusCode = statusCode;
this.details = details;
this.name = 'AppError';
}
}
exports.AppError = AppError;
function errorHandler(err, req, res, next) {
console.error('Error:', err);
if (err instanceof zod_1.ZodError) {
res.status(400).json({
error: {
code: 'INVALID_PAYLOAD',
message: 'Request validation failed',
details: err.flatten().fieldErrors,
},
});
return;
}
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
return;
}
res.status(500).json({
error: {
code: 'SERVER_ERROR',
message: 'Internal server error',
},
});
}
function notFoundHandler(req, res) {
res.status(404).json({
error: {
code: 'NOT_FOUND',
message: `Route ${req.method} ${req.path} not found`,
},
});
}
//# sourceMappingURL=errorHandler.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"errorHandler.js","sourceRoot":"","sources":["../../src/middleware/errorHandler.ts"],"names":[],"mappings":";;;AAeA,oCA+BC;AAED,0CAOC;AAtDD,6BAA+B;AAE/B,MAAa,QAAS,SAAQ,KAAK;IAExB;IACA;IACA;IACA;IAJT,YACS,IAAY,EACZ,OAAe,EACf,aAAqB,GAAG,EACxB,OAA6B;QAEpC,KAAK,CAAC,OAAO,CAAC,CAAC;QALR,SAAI,GAAJ,IAAI,CAAQ;QACZ,YAAO,GAAP,OAAO,CAAQ;QACf,eAAU,GAAV,UAAU,CAAc;QACxB,YAAO,GAAP,OAAO,CAAsB;QAGpC,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;IACzB,CAAC;CACF;AAVD,4BAUC;AAED,SAAgB,YAAY,CAAC,GAAU,EAAE,GAAY,EAAE,GAAa,EAAE,IAAkB;IACtF,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAE7B,IAAI,GAAG,YAAY,cAAQ,EAAE,CAAC;QAC5B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,iBAAiB;gBACvB,OAAO,EAAE,2BAA2B;gBACpC,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,WAAW;aACnC;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,IAAI,GAAG,YAAY,QAAQ,EAAE,CAAC;QAC5B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC;YAC9B,KAAK,EAAE;gBACL,IAAI,EAAE,GAAG,CAAC,IAAI;gBACd,OAAO,EAAE,GAAG,CAAC,OAAO;gBACpB,OAAO,EAAE,GAAG,CAAC,OAAO;aACrB;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;QACnB,KAAK,EAAE;YACL,IAAI,EAAE,cAAc;YACpB,OAAO,EAAE,uBAAuB;SACjC;KACF,CAAC,CAAC;AACL,CAAC;AAED,SAAgB,eAAe,CAAC,GAAY,EAAE,GAAa;IACzD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;QACnB,KAAK,EAAE;YACL,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,SAAS,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,IAAI,YAAY;SACrD;KACF,CAAC,CAAC;AACL,CAAC"}
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=auth.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAmFxB,eAAe,MAAM,CAAC"}
+76
View File
@@ -0,0 +1,76 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const zod_1 = require("zod");
const router = (0, express_1.Router)();
const usernameSchema = zod_1.z
.string()
.min(3)
.max(30)
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username can only contain letters, numbers, dots, dashes and underscores');
const registerSchema = zod_1.z.object({
username: usernameSchema,
password: zod_1.z.string().min(8),
});
const loginSchema = zod_1.z.object({
username: zod_1.z.string(),
password: zod_1.z.string(),
});
// In production, use bcrypt or argon2 for password hashing
function hashPassword(password) {
// Simple hash for demo - replace with bcrypt in production
return Buffer.from(password).toString('base64');
}
function verifyPassword(password, hash) {
return hashPassword(password) === hash;
}
router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = registerSchema.parse(req.body);
const existing = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
if (existing.length > 0) {
throw new errorHandler_1.AppError('USERNAME_EXISTS', 'Username already registered', 409);
}
const userId = (0, auth_1.generateId)('user');
const now = (0, auth_1.getCurrentTimestamp)();
await db_1.db.insert(schema_1.users).values({
id: userId,
username: data.username,
passwordHash: hashPassword(data.password),
createdAt: now,
});
const token = (0, auth_1.generateToken)({ userId, username: data.username });
res.status(201).json({
user: { id: userId, username: data.username },
token,
});
}));
router.post('/login', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = loginSchema.parse(req.body);
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) {
throw new errorHandler_1.AppError('INVALID_CREDENTIALS', 'Invalid username or password', 401);
}
const token = (0, auth_1.generateToken)({ userId: user[0].id, username: user[0].username });
res.json({
user: { id: user[0].id, username: user[0].username },
token,
});
}));
router.get('/me', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
if (!req.user) {
throw new errorHandler_1.AppError('UNAUTHORIZED', 'Authentication required', 401);
}
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.id, req.user.userId)).limit(1);
if (user.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'User not found', 404);
}
res.json({ id: user[0].id, username: user[0].username });
}));
exports.default = router;
//# sourceMappingURL=auth.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"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"}
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=categories.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"categories.d.ts","sourceRoot":"","sources":["../../src/routes/categories.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAqFxB,eAAe,MAAM,CAAC"}
+78
View File
@@ -0,0 +1,78 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware);
router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userCategories = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.eq)(schema_1.categories.userId, req.user.userId))
.orderBy((0, drizzle_orm_1.asc)(schema_1.categories.order), (0, drizzle_orm_1.asc)(schema_1.categories.createdAt));
res.json({ categories: userCategories });
}));
router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.categoryCreateSchema.parse(req.body);
const userId = req.user.userId;
const now = Date.now();
const maxOrder = await db_1.db
.select({ order: schema_1.categories.order })
.from(schema_1.categories)
.where((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId))
.orderBy((0, drizzle_orm_1.desc)(schema_1.categories.order))
.limit(1);
const newCategory = {
id: `cat_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
userId,
name: data.name,
color: data.color,
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
createdAt: now,
updatedAt: now,
};
await db_1.db.insert(schema_1.categories).values(newCategory);
res.status(201).json(newCategory);
}));
router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.categoryUpdateSchema.parse(req.body);
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
}
const now = Date.now();
const updated = await db_1.db
.update(schema_1.categories)
.set({ ...data, updatedAt: now })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.returning();
res.json(updated[0]);
}));
router.delete('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
}
await db_1.db
.delete(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)));
res.status(204).send();
}));
exports.default = router;
//# sourceMappingURL=categories.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"categories.js","sourceRoot":"","sources":["../../src/routes/categories.ts"],"names":[],"mappings":"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"}
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=friends.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"friends.d.ts","sourceRoot":"","sources":["../../src/routes/friends.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAsKxB,eAAe,MAAM,CAAC"}
+130
View File
@@ -0,0 +1,130 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const auth_2 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)();
router.use(auth_2.authMiddleware);
async function findUsernames(ids) {
if (ids.length === 0)
return new Map();
const rows = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.or)(...ids.map((id) => (0, drizzle_orm_1.eq)(schema_1.users.id, id))));
return new Map(rows.map((u) => [u.id, u.username]));
}
router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const outgoingRows = await db_1.db
.select()
.from(schema_1.friendships)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.status, 'pending')));
const incomingRows = await db_1.db
.select()
.from(schema_1.friendships)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.status, 'pending')));
const acceptedRows = await db_1.db
.select()
.from(schema_1.friendships)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.eq)(schema_1.friendships.status, 'accepted')));
const friendIds = acceptedRows.map((r) => (r.userId === userId ? r.friendId : r.userId));
const outgoingIds = outgoingRows.map((r) => r.friendId);
const incomingIds = incomingRows.map((r) => r.userId);
const usernames = await findUsernames([...friendIds, ...outgoingIds, ...incomingIds]);
const friends = acceptedRows.map((r) => {
const friendId = r.userId === userId ? r.friendId : r.userId;
return { id: friendId, username: usernames.get(friendId) ?? '' };
});
const outgoing = outgoingRows.map((r) => ({
id: r.id,
username: usernames.get(r.friendId) ?? '',
requestId: r.id,
status: 'pending',
}));
const incoming = incomingRows.map((r) => ({
id: r.userId,
username: usernames.get(r.userId) ?? '',
requestId: r.id,
status: 'pending',
}));
res.json({ friends, incoming, outgoing });
}));
router.post('/requests', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const { username } = validation_1.friendRequestSchema.parse(req.body);
const userId = req.user.userId;
if (username.toLowerCase() === (await meUsername(userId))) {
throw new errorHandler_1.AppError('SELF_REQUEST', 'You cannot add yourself', 400);
}
const target = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, username)).limit(1);
if (target.length === 0) {
throw new errorHandler_1.AppError('USER_NOT_FOUND', 'No user with that username found', 404);
}
const existing = await db_1.db
.select()
.from(schema_1.friendships)
.where((0, drizzle_orm_1.or)((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, target[0].id)), (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, target[0].id), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId))))
.limit(1);
if (existing.length > 0) {
throw new errorHandler_1.AppError('ALREADY_FRIENDS', existing[0].status === 'accepted' ? 'You are already friends' : 'Friend request already pending', 409);
}
const now = Date.now();
await db_1.db.insert(schema_1.friendships).values({
id: (0, auth_1.generateId)('friend'),
userId,
friendId: target[0].id,
status: 'pending',
createdAt: now,
updatedAt: now,
});
res.status(201).json({ success: true });
}));
router.post('/requests/:id/accept', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const requestId = req.params.id;
const row = await db_1.db.select().from(schema_1.friendships).where((0, drizzle_orm_1.eq)(schema_1.friendships.id, requestId)).limit(1);
if (row.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Request not found', 404);
}
if (row[0].friendId !== userId) {
throw new errorHandler_1.AppError('FORBIDDEN', 'This request was not sent to you', 403);
}
if (row[0].status !== 'pending') {
throw new errorHandler_1.AppError('INVALID_STATE', 'Request is no longer pending', 409);
}
await db_1.db
.update(schema_1.friendships)
.set({ status: 'accepted', updatedAt: Date.now() })
.where((0, drizzle_orm_1.eq)(schema_1.friendships.id, requestId));
res.json({ success: true });
}));
router.delete('/requests/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const requestId = req.params.id;
const row = await db_1.db.select().from(schema_1.friendships).where((0, drizzle_orm_1.eq)(schema_1.friendships.id, requestId)).limit(1);
if (row.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Request not found', 404);
}
if (row[0].userId !== userId && row[0].friendId !== userId) {
throw new errorHandler_1.AppError('FORBIDDEN', 'Not allowed', 403);
}
await db_1.db.delete(schema_1.friendships).where((0, drizzle_orm_1.eq)(schema_1.friendships.id, requestId));
res.json({ success: true });
}));
router.delete('/:friendId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const friendId = req.params.friendId;
await db_1.db
.delete(schema_1.friendships)
.where((0, drizzle_orm_1.or)((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, friendId)), (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, friendId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId))));
res.json({ success: true });
}));
async function meUsername(userId) {
const me = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.id, userId)).limit(1);
return me.length > 0 ? me[0].username.toLowerCase() : '';
}
exports.default = router;
//# sourceMappingURL=friends.js.map
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=subtasks.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"subtasks.d.ts","sourceRoot":"","sources":["../../src/routes/subtasks.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAmIxB,eAAe,MAAM,CAAC"}
+114
View File
@@ -0,0 +1,114 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware);
router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
// Verify task exists and belongs to user
const task = await db_1.db
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
}
const taskSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
res.json({ subtasks: taskSubtasks });
}));
router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.subtaskCreateSchema.parse(req.body);
const userId = req.user.userId;
const task = await db_1.db
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
}
const now = Date.now();
const maxOrder = await db_1.db
.select({ order: schema_1.subtasks.order })
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.orderBy((0, drizzle_orm_1.desc)(schema_1.subtasks.order))
.limit(1);
const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newSubtask = {
id: subtaskId,
userId,
taskId: req.params.taskId,
title: data.title,
completed: false,
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
createdAt: now,
updatedAt: now,
};
await db_1.db.insert(schema_1.subtasks).values(newSubtask);
// Update task updatedAt
await db_1.db
.update(schema_1.tasks)
.set({ updatedAt: now })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
res.status(201).json(newSubtask);
}));
router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.subtaskUpdateSchema.parse(req.body);
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Subtask not found', 404);
}
const now = Date.now();
const updated = await db_1.db
.update(schema_1.subtasks)
.set({ ...data, updatedAt: now })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.returning();
// Update task updatedAt
await db_1.db
.update(schema_1.tasks)
.set({ updatedAt: now })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, existing[0].taskId), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
res.json(updated[0]);
}));
router.delete('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Subtask not found', 404);
}
const taskId = existing[0].taskId;
await db_1.db
.delete(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
// Update task updatedAt
await db_1.db
.update(schema_1.tasks)
.set({ updatedAt: Date.now() })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, taskId), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
res.status(204).send();
}));
exports.default = router;
//# sourceMappingURL=subtasks.js.map
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=sync.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8SxB,eAAe,MAAM,CAAC"}
+289
View File
@@ -0,0 +1,289 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware);
router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const { since } = validation_1.syncQuerySchema.parse(req.query);
const userId = req.user.userId;
const sinceDate = since;
// Fetch categories changed since timestamp
const changedCategories = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId), (0, drizzle_orm_1.gte)(schema_1.categories.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.categories.updatedAt));
// Fetch tasks changed since timestamp
const changedTasks = await db_1.db
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt));
// Fetch subtasks changed since timestamp
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks = [];
if (taskIds.length > 0) {
changedSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.subtasks.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
}
else {
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt)
changedSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.subtasks.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
}
// Fetch repeat profiles changed since timestamp
const changedRepeatProfiles = await db_1.db
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId), (0, drizzle_orm_1.gte)(schema_1.repeatProfiles.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.repeatProfiles.updatedAt));
// Fetch friendships changed since timestamp
const changedFriendships = await db_1.db
.select()
.from(schema_1.friendships)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt));
const timestamp = Date.now();
res.json({
categories: changedCategories,
tasks: changedTasks,
subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships,
timestamp,
});
}));
router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.pushChangesSchema.parse(req.body);
const userId = req.user.userId;
const conflicts = [];
const timestamp = Date.now();
try {
await db_1.db.transaction(async (tx) => {
// Process categories
if (data.changes.categories && data.changes.categories.length > 0) {
for (const cat of data.changes.categories) {
const existing = await tx
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, cat.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > cat.updatedAt) {
conflicts.push({
entity: 'categories',
id: cat.id,
serverVersion: existing[0],
clientVersion: cat,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(schema_1.categories)
.set({
name: cat.name,
color: cat.color,
order: cat.order,
updatedAt: cat.updatedAt,
})
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, cat.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)));
}
else {
await tx.insert(schema_1.categories).values({
...cat,
userId,
});
}
}
}
// Process tasks
if (data.changes.tasks && data.changes.tasks.length > 0) {
for (const task of data.changes.tasks) {
const existing = await tx
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > task.updatedAt) {
conflicts.push({
entity: 'tasks',
id: task.id,
serverVersion: existing[0],
clientVersion: task,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(schema_1.tasks)
.set({
title: task.title,
description: task.description,
categoryId: task.categoryId,
priority: task.priority,
completed: task.completed,
dueDate: task.dueDate,
dueTime: task.dueTime,
endTime: task.endTime ?? '',
repeat: task.repeat ?? 'none',
repeatInterval: task.repeatInterval ?? 1,
repeatDays: task.repeatDays ?? '',
seriesId: task.seriesId ?? '',
reminder: task.reminder ?? 'none',
assigneeId: task.assigneeId ?? null,
updatedAt: task.updatedAt,
})
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
}
else {
await tx.insert(schema_1.tasks).values({
...task,
userId,
});
}
}
}
// Process subtasks
if (data.changes.subtasks && data.changes.subtasks.length > 0) {
for (const sub of data.changes.subtasks) {
const existing = await tx
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, sub.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > sub.updatedAt) {
conflicts.push({
entity: 'subtasks',
id: sub.id,
serverVersion: existing[0],
clientVersion: sub,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(schema_1.subtasks)
.set({
taskId: sub.taskId,
title: sub.title,
completed: sub.completed,
order: sub.order,
updatedAt: sub.updatedAt,
})
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, sub.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
}
else {
await tx.insert(schema_1.subtasks).values({
...sub,
userId,
});
}
}
}
// Process repeat profiles
if (data.changes.repeatProfiles && data.changes.repeatProfiles.length > 0) {
for (const profile of data.changes.repeatProfiles) {
const existing = await tx
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, profile.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > profile.updatedAt) {
conflicts.push({
entity: 'repeatProfiles',
id: profile.id,
serverVersion: existing[0],
clientVersion: profile,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(schema_1.repeatProfiles)
.set({
name: profile.name,
repeat: profile.repeat,
repeatInterval: profile.repeatInterval,
repeatDays: profile.repeatDays,
updatedAt: profile.updatedAt,
})
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, profile.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
}
else {
await tx.insert(schema_1.repeatProfiles).values({
...profile,
userId,
});
}
}
}
// Process friendships
if (data.changes.friendships && data.changes.friendships.length > 0) {
for (const friendship of data.changes.friendships) {
const existing = await tx
.select()
.from(schema_1.friendships)
.where((0, drizzle_orm_1.eq)(schema_1.friendships.id, friendship.id))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > friendship.updatedAt) {
conflicts.push({
entity: 'friendships',
id: friendship.id,
serverVersion: existing[0],
clientVersion: friendship,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(schema_1.friendships)
.set({
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
updatedAt: friendship.updatedAt,
})
.where((0, drizzle_orm_1.eq)(schema_1.friendships.id, friendship.id));
}
else {
await tx.insert(schema_1.friendships).values(friendship);
}
}
}
});
}
catch (error) {
console.error('Sync push error:', error);
throw new errorHandler_1.AppError('SERVER_ERROR', 'Failed to process sync push', 500);
}
res.json({
success: true,
timestamp,
conflicts,
});
}));
exports.default = router;
//# sourceMappingURL=sync.js.map
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=tasks.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/routes/tasks.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8RxB,eAAe,MAAM,CAAC"}
+254
View File
@@ -0,0 +1,254 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const zod_1 = require("zod");
const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware);
function applyTaskFilters(query, userId, filters) {
const conditions = [(0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)];
if (filters.categoryId) {
conditions.push((0, drizzle_orm_1.eq)(schema_1.tasks.categoryId, filters.categoryId));
}
if (filters.completed !== undefined) {
conditions.push((0, drizzle_orm_1.eq)(schema_1.tasks.completed, filters.completed));
}
if (filters.dueBefore) {
conditions.push((0, drizzle_orm_1.lte)(schema_1.tasks.dueDate, filters.dueBefore));
}
if (filters.dueAfter) {
conditions.push((0, drizzle_orm_1.gte)(schema_1.tasks.dueDate, filters.dueAfter));
}
if (filters.priority) {
conditions.push((0, drizzle_orm_1.eq)(schema_1.tasks.priority, filters.priority));
}
return query.where((0, drizzle_orm_1.and)(...conditions));
}
function applyTaskSorting(query, sortBy = 'dueDate', sortOrder = 'asc') {
const orderFn = sortOrder === 'desc' ? drizzle_orm_1.desc : drizzle_orm_1.asc;
const columnMap = {
dueDate: schema_1.tasks.dueDate,
priority: schema_1.tasks.priority,
createdAt: schema_1.tasks.createdAt,
title: schema_1.tasks.title,
};
return query.orderBy(orderFn(columnMap[sortBy] || schema_1.tasks.dueDate));
}
router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const filters = validation_1.taskQuerySchema.parse(req.query);
const userId = req.user.userId;
let query = db_1.db.select().from(schema_1.tasks);
query = applyTaskFilters(query, userId, filters);
query = applyTaskSorting(query, filters.sortBy, filters.sortOrder);
const limit = filters.limit ?? 50;
const offset = filters.offset ?? 0;
query = query.limit(limit).offset(offset);
const results = await query;
// Fetch subtasks for each task
const taskIds = results.map((t) => t.id);
let taskSubtasks = [];
if (taskIds.length > 0) {
taskSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), (0, drizzle_orm_1.inArray)(schema_1.subtasks.taskId, taskIds)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
}
const subtasksByTask = taskSubtasks.reduce((acc, st) => {
if (!acc[st.taskId])
acc[st.taskId] = [];
acc[st.taskId].push(st);
return acc;
}, {});
const tasksWithSubtasks = results.map((task) => ({
...task,
subtasks: subtasksByTask[task.id] || [],
}));
// Get total count
const countQuery = db_1.db.select({ count: (0, drizzle_orm_1.sql) `count(*)` }).from(schema_1.tasks);
const countResult = await applyTaskFilters(countQuery, userId, filters);
const total = Number(countResult[0]?.count ?? 0);
res.json({ tasks: tasksWithSubtasks, total, limit, offset });
}));
router.get('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const task = await db_1.db
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
}
const taskSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
res.json({ ...task[0], subtasks: taskSubtasks });
}));
router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.taskCreateSchema.parse(req.body);
const userId = req.user.userId;
const now = Date.now();
// Verify category exists and belongs to user
const cat = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
}
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newTask = {
id: taskId,
userId,
categoryId: data.categoryId,
title: data.title,
description: data.description ?? '',
priority: data.priority ?? 'none',
completed: false,
dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '',
assigneeId: data.assigneeId ?? null,
createdAt: now,
updatedAt: now,
};
await db_1.db.insert(schema_1.tasks).values(newTask);
// Create subtasks if provided
if (data.subtasks && data.subtasks.length > 0) {
const subtaskValues = data.subtasks.map((st, index) => ({
id: `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}${index}`,
userId,
taskId,
title: st.title,
completed: false,
order: index,
createdAt: now,
updatedAt: now,
}));
await db_1.db.insert(schema_1.subtasks).values(subtaskValues);
}
const createdSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
res.status(201).json({ ...newTask, subtasks: createdSubtasks });
}));
router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.taskUpdateSchema.parse(req.body);
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
}
// Verify category if provided
if (data.categoryId) {
const cat = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
}
}
const now = Date.now();
const updated = await db_1.db
.update(schema_1.tasks)
.set({ ...data, updatedAt: now })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.returning();
const taskSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
res.json({ ...updated[0], subtasks: taskSubtasks });
}));
router.delete('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
}
await db_1.db
.delete(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
res.status(204).send();
}));
router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const { operations } = zod_1.z.object({
operations: zod_1.z.array(zod_1.z.union([
zod_1.z.object({ type: zod_1.z.literal('create'), data: validation_1.taskCreateSchema }),
zod_1.z.object({ type: zod_1.z.literal('update'), id: zod_1.z.string(), data: validation_1.taskUpdateSchema }),
zod_1.z.object({ type: zod_1.z.literal('delete'), id: zod_1.z.string() }),
])),
}).parse(req.body);
const userId = req.user.userId;
const results = [];
for (const op of operations) {
try {
if (op.type === 'create') {
const cat = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, op.data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (cat.length === 0)
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const now = Date.now();
await db_1.db.insert(schema_1.tasks).values({
id: taskId,
userId,
...op.data,
completed: false,
createdAt: now,
updatedAt: now,
});
results.push({ id: taskId, success: true });
}
else if (op.type === 'update') {
await db_1.db
.update(schema_1.tasks)
.set({ ...op.data, updatedAt: Date.now() })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, op.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
results.push({ id: op.id, success: true });
}
else if (op.type === 'delete') {
await db_1.db
.delete(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, op.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
results.push({ id: op.id, success: true });
}
}
catch (error) {
results.push({
id: 'id' in op ? op.id : 'unknown',
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
res.json({ results });
}));
exports.default = router;
//# sourceMappingURL=tasks.js.map
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=users.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/routes/users.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAqGxB,eAAe,MAAM,CAAC"}
+98
View File
@@ -0,0 +1,98 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware);
router.get('/me', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.id, userId)).limit(1);
if (user.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'User not found', 404);
}
const settings = await db_1.db
.select()
.from(schema_1.userSettings)
.where((0, drizzle_orm_1.eq)(schema_1.userSettings.userId, userId))
.limit(1);
let defaultCategory = settings[0]?.defaultCategory;
if (defaultCategory) {
const cat = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, defaultCategory), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
defaultCategory = null;
}
}
res.json({
id: user[0].id,
username: user[0].username,
settings: {
darkMode: settings[0]?.darkMode ?? false,
notifications: settings[0]?.notifications ?? true,
reminderTime: settings[0]?.reminderTime ?? '09:00',
defaultCategory,
sortBy: settings[0]?.sortBy ?? 'dueDate',
sortOrder: settings[0]?.sortOrder ?? 'asc',
},
createdAt: user[0].createdAt,
});
}));
router.patch('/me/settings', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.userSettingsSchema.parse(req.body);
const userId = req.user.userId;
// Validate defaultCategory if provided
if (data.defaultCategory) {
const cat = await db_1.db
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, data.defaultCategory), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Default category not found', 404);
}
}
const now = Date.now();
const existing = await db_1.db
.select()
.from(schema_1.userSettings)
.where((0, drizzle_orm_1.eq)(schema_1.userSettings.userId, userId))
.limit(1);
if (existing.length === 0) {
await db_1.db.insert(schema_1.userSettings).values({
userId,
...data,
updatedAt: now,
});
}
else {
await db_1.db
.update(schema_1.userSettings)
.set({ ...data, updatedAt: now })
.where((0, drizzle_orm_1.eq)(schema_1.userSettings.userId, userId));
}
res.status(200).json({ success: true });
}));
router.get('/search', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const { q } = req.query;
const userId = req.user.userId;
if (!q || typeof q !== 'string' || q.length < 2) {
return res.json([]);
}
const results = await db_1.db
.select({ id: schema_1.users.id, username: schema_1.users.username })
.from(schema_1.users)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.ilike)(schema_1.users.username, `%${q}%`), (0, drizzle_orm_1.eq)(schema_1.users.id, userId)))
.limit(10);
res.json(results);
}));
exports.default = router;
//# sourceMappingURL=users.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"users.js","sourceRoot":"","sources":["../../src/routes/users.ts"],"names":[],"mappings":"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"}
+83
View File
@@ -0,0 +1,83 @@
export interface Category {
id: string;
name: string;
color: string;
order: number;
createdAt: number;
updatedAt: number;
}
export interface Task {
id: string;
title: string;
description: string;
categoryId: string;
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
completed: boolean;
dueDate: number;
dueTime: string;
endTime: string;
assigneeId: string | null;
createdAt: number;
updatedAt: number;
subtasks?: Subtask[];
}
export interface Subtask {
id: string;
taskId: string;
title: string;
completed: boolean;
order: number;
createdAt: number;
updatedAt: number;
}
export interface UserSettings {
darkMode: boolean;
notifications: boolean;
reminderTime: string;
defaultCategory: string | null;
sortBy: 'dueDate' | 'priority' | 'title' | 'createdAt';
sortOrder: 'asc' | 'desc';
}
export interface User {
id: string;
username: string;
settings: UserSettings;
createdAt: number;
}
export interface SyncResponse {
categories: Category[];
tasks: Task[];
subtasks: Subtask[];
timestamp: number;
}
export interface PushChangesRequest {
changes: {
categories: Category[];
tasks: Task[];
subtasks: Subtask[];
};
lastPulledAt: number;
}
export interface PushChangesResponse {
success: boolean;
timestamp: number;
conflicts: Array<{
entity: string;
id: string;
serverVersion: any;
clientVersion: any;
resolution: 'server_wins' | 'client_wins' | 'merge';
}>;
}
export interface AuthPayload {
userId: string;
username: string;
}
declare global {
namespace Express {
interface Request {
user?: AuthPayload;
}
}
}
//# sourceMappingURL=index.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,CAAC;IAC1D,SAAS,EAAE,OAAO,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,WAAW,CAAC;IACvD,SAAS,EAAE,KAAK,GAAG,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,QAAQ,EAAE,CAAC;IACvB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE;QACP,UAAU,EAAE,QAAQ,EAAE,CAAC;QACvB,KAAK,EAAE,IAAI,EAAE,CAAC;QACd,QAAQ,EAAE,OAAO,EAAE,CAAC;KACrB,CAAC;IACF,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,KAAK,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;QACf,EAAE,EAAE,MAAM,CAAC;QACX,aAAa,EAAE,GAAG,CAAC;QACnB,aAAa,EAAE,GAAG,CAAC;QACnB,UAAU,EAAE,aAAa,GAAG,aAAa,GAAG,OAAO,CAAC;KACrD,CAAC,CAAC;CACJ;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,OAAO;YACf,IAAI,CAAC,EAAE,WAAW,CAAC;SACpB;KACF;CACF"}
+3
View File
@@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":""}
+5
View File
@@ -0,0 +1,5 @@
import { Request, Response, NextFunction } from 'express';
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
export declare function asyncHandler(handler: AsyncHandler): (req: Request, res: Response, next: NextFunction) => void;
export {};
//# sourceMappingURL=asyncHandler.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"asyncHandler.d.ts","sourceRoot":"","sources":["../../src/utils/asyncHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,KAAK,YAAY,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE1F,wBAAgB,YAAY,CAAC,OAAO,EAAE,YAAY,IACxC,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,KAAG,IAAI,CAG/D"}
+9
View File
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.asyncHandler = asyncHandler;
function asyncHandler(handler) {
return (req, res, next) => {
handler(req, res, next).catch(next);
};
}
//# sourceMappingURL=asyncHandler.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"asyncHandler.js","sourceRoot":"","sources":["../../src/utils/asyncHandler.ts"],"names":[],"mappings":";;AAIA,oCAIC;AAJD,SAAgB,YAAY,CAAC,OAAqB;IAChD,OAAO,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAQ,EAAE;QAC/D,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC,CAAC;AACJ,CAAC"}
+9
View File
@@ -0,0 +1,9 @@
import { Request, Response, NextFunction } from 'express';
import { AuthPayload } from '../types';
export declare function generateToken(payload: AuthPayload): string;
export declare function verifyToken(token: string): AuthPayload | null;
export declare function authMiddleware(req: Request, res: Response, next: NextFunction): void;
export declare function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction): void;
export declare function generateId(prefix?: string): string;
export declare function getCurrentTimestamp(): number;
//# sourceMappingURL=auth.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAKvC,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAM7D;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CA4BpF;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAW5F;AAED,wBAAgB,UAAU,CAAC,MAAM,GAAE,MAAW,GAAG,MAAM,CAItD;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C"}
+70
View File
@@ -0,0 +1,70 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateToken = generateToken;
exports.verifyToken = verifyToken;
exports.authMiddleware = authMiddleware;
exports.optionalAuthMiddleware = optionalAuthMiddleware;
exports.generateId = generateId;
exports.getCurrentTimestamp = getCurrentTimestamp;
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
const JWT_EXPIRES_IN = '7d';
function generateToken(payload) {
return jsonwebtoken_1.default.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
function verifyToken(token) {
try {
return jsonwebtoken_1.default.verify(token, JWT_SECRET);
}
catch {
return null;
}
}
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Missing or invalid authorization header',
},
});
return;
}
const token = authHeader.slice(7);
const payload = verifyToken(token);
if (!payload) {
res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid or expired token',
},
});
return;
}
req.user = payload;
next();
}
function optionalAuthMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const payload = verifyToken(token);
if (payload) {
req.user = payload;
}
}
next();
}
function generateId(prefix = '') {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).slice(2, 10);
return `${prefix}${prefix ? '_' : ''}${timestamp}${random}`;
}
function getCurrentTimestamp() {
return Date.now();
}
//# sourceMappingURL=auth.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":";;;;;AAOA,sCAEC;AAED,kCAMC;AAED,wCA4BC;AAED,wDAWC;AAED,gCAIC;AAED,kDAEC;AArED,gEAA+B;AAG/B,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,gDAAgD,CAAC;AAC9F,MAAM,cAAc,GAAG,IAAI,CAAC;AAE5B,SAAgB,aAAa,CAAC,OAAoB;IAChD,OAAO,sBAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAC;AACtE,CAAC;AAED,SAAgB,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC;QACH,OAAO,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAgB,cAAc,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAC5E,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,CAAC,UAAU,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,yCAAyC;aACnD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,0BAA0B;aACpC;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;IACnB,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,sBAAsB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IACpF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,UAAU,IAAI,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACnD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,OAAO,EAAE,CAAC;YACZ,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;QACrB,CAAC;IACH,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,UAAU,CAAC,SAAiB,EAAE;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvD,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;AAC9D,CAAC;AAED,SAAgB,mBAAmB;IACjC,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC;AACpB,CAAC"}
+649
View File
@@ -0,0 +1,649 @@
import { z } from 'zod';
export declare const categoryCreateSchema: z.ZodObject<{
name: z.ZodString;
color: z.ZodString;
order: z.ZodOptional<z.ZodNumber>;
}, "strip", z.ZodTypeAny, {
name: string;
color: string;
order?: number | undefined;
}, {
name: string;
color: string;
order?: number | undefined;
}>;
export declare const categoryUpdateSchema: z.ZodObject<{
name: z.ZodOptional<z.ZodString>;
color: z.ZodOptional<z.ZodString>;
order: z.ZodOptional<z.ZodNumber>;
}, "strip", z.ZodTypeAny, {
name?: string | undefined;
color?: string | undefined;
order?: number | undefined;
}, {
name?: string | undefined;
color?: string | undefined;
order?: number | undefined;
}>;
export declare const repeatSchema: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>;
export declare const taskCreateSchema: z.ZodObject<{
title: z.ZodString;
description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodString;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
repeatInterval: z.ZodOptional<z.ZodNumber>;
repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString;
}, "strip", z.ZodTypeAny, {
title: string;
}, {
title: string;
}>, "many">>;
}, "strip", z.ZodTypeAny, {
categoryId: string;
title: string;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: {
title: string;
}[] | undefined;
}, {
categoryId: string;
title: string;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: {
title: string;
}[] | undefined;
}>;
export declare const taskUpdateSchema: z.ZodObject<{
title: z.ZodOptional<z.ZodString>;
description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodOptional<z.ZodString>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
completed: z.ZodOptional<z.ZodBoolean>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>;
endTime: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, "strip", z.ZodTypeAny, {
categoryId?: string | undefined;
title?: string | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
assigneeId?: string | null | undefined;
}, {
categoryId?: string | undefined;
title?: string | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
assigneeId?: string | null | undefined;
}>;
export declare const subtaskCreateSchema: z.ZodObject<{
title: z.ZodString;
order: z.ZodOptional<z.ZodNumber>;
}, "strip", z.ZodTypeAny, {
title: string;
order?: number | undefined;
}, {
title: string;
order?: number | undefined;
}>;
export declare const subtaskUpdateSchema: z.ZodObject<{
title: z.ZodOptional<z.ZodString>;
completed: z.ZodOptional<z.ZodBoolean>;
order: z.ZodOptional<z.ZodNumber>;
}, "strip", z.ZodTypeAny, {
order?: number | undefined;
title?: string | undefined;
completed?: boolean | undefined;
}, {
order?: number | undefined;
title?: string | undefined;
completed?: boolean | undefined;
}>;
export declare const userSettingsSchema: z.ZodObject<{
darkMode: z.ZodOptional<z.ZodBoolean>;
notifications: z.ZodOptional<z.ZodBoolean>;
reminderTime: z.ZodOptional<z.ZodString>;
defaultCategory: z.ZodNullable<z.ZodOptional<z.ZodString>>;
sortBy: z.ZodOptional<z.ZodEnum<["dueDate", "priority", "title", "createdAt"]>>;
sortOrder: z.ZodOptional<z.ZodEnum<["asc", "desc"]>>;
}, "strip", z.ZodTypeAny, {
darkMode?: boolean | undefined;
notifications?: boolean | undefined;
reminderTime?: string | undefined;
defaultCategory?: string | null | undefined;
sortBy?: "createdAt" | "title" | "priority" | "dueDate" | undefined;
sortOrder?: "asc" | "desc" | undefined;
}, {
darkMode?: boolean | undefined;
notifications?: boolean | undefined;
reminderTime?: string | undefined;
defaultCategory?: string | null | undefined;
sortBy?: "createdAt" | "title" | "priority" | "dueDate" | undefined;
sortOrder?: "asc" | "desc" | undefined;
}>;
export declare const repeatProfileSchema: z.ZodObject<{
name: z.ZodString;
repeat: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>;
repeatInterval: z.ZodNumber;
repeatDays: z.ZodString;
}, "strip", z.ZodTypeAny, {
name: string;
repeat: "custom" | "none" | "daily" | "weekly" | "monthly";
repeatInterval: number;
repeatDays: string;
}, {
name: string;
repeat: "custom" | "none" | "daily" | "weekly" | "monthly";
repeatInterval: number;
repeatDays: string;
}>;
export declare const friendRequestSchema: z.ZodObject<{
username: z.ZodString;
}, "strip", z.ZodTypeAny, {
username: string;
}, {
username: string;
}>;
export declare const friendshipSchema: z.ZodObject<{
id: z.ZodString;
userId: z.ZodString;
friendId: z.ZodString;
status: z.ZodEnum<["pending", "accepted"]>;
createdAt: z.ZodNumber;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}, {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}>;
export declare const pushChangesSchema: z.ZodObject<{
changes: z.ZodObject<{
categories: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
color: z.ZodString;
order: z.ZodOptional<z.ZodNumber>;
} & {
id: z.ZodString;
createdAt: z.ZodNumber;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
name: string;
createdAt: number;
updatedAt: number;
color: string;
order?: number | undefined;
}, {
id: string;
name: string;
createdAt: number;
updatedAt: number;
color: string;
order?: number | undefined;
}>, "many">>;
tasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString;
description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodString;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
repeatInterval: z.ZodOptional<z.ZodNumber>;
repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString;
}, "strip", z.ZodTypeAny, {
title: string;
}, {
title: string;
}>, "many">>;
} & {
id: z.ZodString;
completed: z.ZodBoolean;
createdAt: z.ZodNumber;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
createdAt: number;
updatedAt: number;
categoryId: string;
title: string;
completed: boolean;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: {
title: string;
}[] | undefined;
}, {
id: string;
createdAt: number;
updatedAt: number;
categoryId: string;
title: string;
completed: boolean;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: {
title: string;
}[] | undefined;
}>, "many">>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString;
order: z.ZodOptional<z.ZodNumber>;
} & {
id: z.ZodString;
taskId: z.ZodString;
completed: z.ZodBoolean;
createdAt: z.ZodNumber;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
createdAt: number;
updatedAt: number;
title: string;
completed: boolean;
taskId: string;
order?: number | undefined;
}, {
id: string;
createdAt: number;
updatedAt: number;
title: string;
completed: boolean;
taskId: string;
order?: number | undefined;
}>, "many">>;
repeatProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
repeat: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>;
repeatInterval: z.ZodNumber;
repeatDays: z.ZodString;
} & {
id: z.ZodString;
createdAt: z.ZodNumber;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
name: string;
createdAt: number;
updatedAt: number;
repeat: "custom" | "none" | "daily" | "weekly" | "monthly";
repeatInterval: number;
repeatDays: string;
}, {
id: string;
name: string;
createdAt: number;
updatedAt: number;
repeat: "custom" | "none" | "daily" | "weekly" | "monthly";
repeatInterval: number;
repeatDays: string;
}>, "many">>;
friendships: z.ZodOptional<z.ZodArray<z.ZodObject<{
id: z.ZodString;
userId: z.ZodString;
friendId: z.ZodString;
status: z.ZodEnum<["pending", "accepted"]>;
createdAt: z.ZodNumber;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}, {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}>, "many">>;
}, "strip", z.ZodTypeAny, {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
color: string;
order?: number | undefined;
}[] | undefined;
tasks?: {
id: string;
createdAt: number;
updatedAt: number;
categoryId: string;
title: string;
completed: boolean;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: {
title: string;
}[] | undefined;
}[] | undefined;
subtasks?: {
id: string;
createdAt: number;
updatedAt: number;
title: string;
completed: boolean;
taskId: string;
order?: number | undefined;
}[] | undefined;
repeatProfiles?: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
repeat: "custom" | "none" | "daily" | "weekly" | "monthly";
repeatInterval: number;
repeatDays: string;
}[] | undefined;
}, {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
color: string;
order?: number | undefined;
}[] | undefined;
tasks?: {
id: string;
createdAt: number;
updatedAt: number;
categoryId: string;
title: string;
completed: boolean;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: {
title: string;
}[] | undefined;
}[] | undefined;
subtasks?: {
id: string;
createdAt: number;
updatedAt: number;
title: string;
completed: boolean;
taskId: string;
order?: number | undefined;
}[] | undefined;
repeatProfiles?: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
repeat: "custom" | "none" | "daily" | "weekly" | "monthly";
repeatInterval: number;
repeatDays: string;
}[] | undefined;
}>;
lastPulledAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
changes: {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
color: string;
order?: number | undefined;
}[] | undefined;
tasks?: {
id: string;
createdAt: number;
updatedAt: number;
categoryId: string;
title: string;
completed: boolean;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: {
title: string;
}[] | undefined;
}[] | undefined;
subtasks?: {
id: string;
createdAt: number;
updatedAt: number;
title: string;
completed: boolean;
taskId: string;
order?: number | undefined;
}[] | undefined;
repeatProfiles?: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
repeat: "custom" | "none" | "daily" | "weekly" | "monthly";
repeatInterval: number;
repeatDays: string;
}[] | undefined;
};
lastPulledAt: number;
}, {
changes: {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
color: string;
order?: number | undefined;
}[] | undefined;
tasks?: {
id: string;
createdAt: number;
updatedAt: number;
categoryId: string;
title: string;
completed: boolean;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: {
title: string;
}[] | undefined;
}[] | undefined;
subtasks?: {
id: string;
createdAt: number;
updatedAt: number;
title: string;
completed: boolean;
taskId: string;
order?: number | undefined;
}[] | undefined;
repeatProfiles?: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
repeat: "custom" | "none" | "daily" | "weekly" | "monthly";
repeatInterval: number;
repeatDays: string;
}[] | undefined;
};
lastPulledAt: number;
}>;
export declare const syncQuerySchema: z.ZodObject<{
since: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>;
}, "strip", z.ZodTypeAny, {
since: number;
}, {
since: string;
}>;
export declare const taskQuerySchema: z.ZodObject<{
categoryId: z.ZodOptional<z.ZodString>;
completed: z.ZodOptional<z.ZodEffects<z.ZodString, boolean, string>>;
dueBefore: z.ZodOptional<z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>>;
dueAfter: z.ZodOptional<z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
sortBy: z.ZodOptional<z.ZodEnum<["dueDate", "priority", "createdAt", "title"]>>;
sortOrder: z.ZodOptional<z.ZodEnum<["asc", "desc"]>>;
limit: z.ZodOptional<z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>>;
offset: z.ZodOptional<z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>>;
}, "strip", z.ZodTypeAny, {
categoryId?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined;
sortBy?: "createdAt" | "title" | "priority" | "dueDate" | undefined;
sortOrder?: "asc" | "desc" | undefined;
limit?: number | undefined;
offset?: number | undefined;
dueBefore?: number | undefined;
dueAfter?: number | undefined;
}, {
categoryId?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: string | undefined;
sortBy?: "createdAt" | "title" | "priority" | "dueDate" | undefined;
sortOrder?: "asc" | "desc" | undefined;
limit?: string | undefined;
offset?: string | undefined;
dueBefore?: string | undefined;
dueAfter?: string | undefined;
}>;
//# sourceMappingURL=validation.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,YAAY,6DAA2D,CAAC;AAErF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAe3B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU3B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;EAG9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;EAI9B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;EAO7B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;EAK9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;EAE9B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;EAO3B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAS5B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;EAE1B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU1B,CAAC"}
+101
View File
@@ -0,0 +1,101 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.taskQuerySchema = exports.syncQuerySchema = exports.pushChangesSchema = exports.friendshipSchema = exports.friendRequestSchema = exports.repeatProfileSchema = exports.userSettingsSchema = exports.subtaskUpdateSchema = exports.subtaskCreateSchema = exports.taskUpdateSchema = exports.taskCreateSchema = exports.repeatSchema = exports.categoryUpdateSchema = exports.categoryCreateSchema = void 0;
const zod_1 = require("zod");
exports.categoryCreateSchema = zod_1.z.object({
name: zod_1.z.string().min(1).max(50),
color: zod_1.z.string().regex(/^#[0-9A-Fa-f]{6}$/),
order: zod_1.z.number().int().min(0).optional(),
});
exports.categoryUpdateSchema = zod_1.z.object({
name: zod_1.z.string().min(1).max(50).optional(),
color: zod_1.z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
order: zod_1.z.number().int().min(0).optional(),
});
exports.repeatSchema = zod_1.z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
exports.taskCreateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100),
description: zod_1.z.string().max(1000).optional(),
categoryId: zod_1.z.string().min(1),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().nullable(),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')),
repeat: exports.repeatSchema.optional(),
repeatInterval: zod_1.z.number().int().min(1).max(30).optional(),
repeatDays: zod_1.z.string().max(20).optional(),
seriesId: zod_1.z.string().max(50).optional(),
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
assigneeId: zod_1.z.string().nullable().optional(),
subtasks: zod_1.z.array(zod_1.z.object({ title: zod_1.z.string().min(1).max(100) })).optional(),
});
exports.taskUpdateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100).optional(),
description: zod_1.z.string().max(1000).optional(),
categoryId: zod_1.z.string().min(1).optional(),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: zod_1.z.boolean().optional(),
dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().nullable(),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
assigneeId: zod_1.z.string().nullable().optional(),
});
exports.subtaskCreateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100),
order: zod_1.z.number().int().min(0).optional(),
});
exports.subtaskUpdateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100).optional(),
completed: zod_1.z.boolean().optional(),
order: zod_1.z.number().int().min(0).optional(),
});
exports.userSettingsSchema = zod_1.z.object({
darkMode: zod_1.z.boolean().optional(),
notifications: zod_1.z.boolean().optional(),
reminderTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
defaultCategory: zod_1.z.string().optional().nullable(),
sortBy: zod_1.z.enum(['dueDate', 'priority', 'title', 'createdAt']).optional(),
sortOrder: zod_1.z.enum(['asc', 'desc']).optional(),
});
exports.repeatProfileSchema = zod_1.z.object({
name: zod_1.z.string().min(1).max(50),
repeat: exports.repeatSchema,
repeatInterval: zod_1.z.number().int().min(1).max(30),
repeatDays: zod_1.z.string().max(20),
});
exports.friendRequestSchema = zod_1.z.object({
username: zod_1.z.string().min(1).max(50),
});
exports.friendshipSchema = zod_1.z.object({
id: zod_1.z.string(),
userId: zod_1.z.string(),
friendId: zod_1.z.string(),
status: zod_1.z.enum(['pending', 'accepted']),
createdAt: zod_1.z.number(),
updatedAt: zod_1.z.number(),
});
exports.pushChangesSchema = zod_1.z.object({
changes: zod_1.z.object({
categories: zod_1.z.array(exports.categoryCreateSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
tasks: zod_1.z.array(exports.taskCreateSchema.extend({ id: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
subtasks: zod_1.z.array(exports.subtaskCreateSchema.extend({ id: zod_1.z.string(), taskId: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
repeatProfiles: zod_1.z.array(exports.repeatProfileSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
friendships: zod_1.z.array(exports.friendshipSchema).optional(),
}),
lastPulledAt: zod_1.z.number().int().min(0),
});
exports.syncQuerySchema = zod_1.z.object({
since: zod_1.z.string().transform(Number).pipe(zod_1.z.number().int().min(0)),
});
exports.taskQuerySchema = zod_1.z.object({
categoryId: zod_1.z.string().optional(),
completed: zod_1.z.string().transform(v => v === 'true').optional(),
dueBefore: zod_1.z.string().transform(Number).pipe(zod_1.z.number().int().min(0)).optional(),
dueAfter: zod_1.z.string().transform(Number).pipe(zod_1.z.number().int().min(0)).optional(),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
sortBy: zod_1.z.enum(['dueDate', 'priority', 'createdAt', 'title']).optional(),
sortOrder: zod_1.z.enum(['asc', 'desc']).optional(),
limit: zod_1.z.string().transform(Number).pipe(zod_1.z.number().int().min(1).max(100)).optional(),
offset: zod_1.z.string().transform(Number).pipe(zod_1.z.number().int().min(0)).optional(),
});
//# sourceMappingURL=validation.js.map
File diff suppressed because one or more lines are too long
+46
View File
@@ -0,0 +1,46 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: carry-your-live-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: carry_your_live
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: .
target: dev
container_name: carry-your-live-api
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/carry_your_live
JWT_SECRET: your-super-secret-jwt-key-change-in-production-min-32-chars
PORT: 3000
NODE_ENV: development
FRONTEND_URL: http://localhost:8081
ports:
- "3000:3000"
volumes:
- ./:/app
- backend_node_modules:/app/node_modules
command: >
sh -c "npm run db:push || true
&& npm run dev"
volumes:
postgres_data:
backend_node_modules:
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
+3029
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "carry-your-live-backend",
"version": "1.0.0",
"description": "Backend API for Carry Your Live",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.33.0",
"express": "^4.21.0",
"express-rate-limit": "^8.6.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.5.5",
"@types/pg": "^8.11.10",
"drizzle-kit": "^0.24.2",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
});
export const db = drizzle(pool, { schema });
export type DB = typeof db;
+107
View File
@@ -0,0 +1,107 @@
import { pgTable, text, integer, bigint, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const users = pgTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(),
resetToken: text('reset_token'),
resetTokenExpiry: bigint('reset_token_expiry', { mode: 'number' }),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
export const friendships = pgTable(
'friendships',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
friendId: text('friend_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
status: text('status', { enum: ['pending', 'accepted'] }).notNull().default('pending'),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
},
(t) => ({
pairUnique: unique('friendships_pair').on(t.userId, t.friendId),
userIdx: index('friendships_user_idx').on(t.userId),
friendIdx: index('friendships_friend_idx').on(t.friendId),
})
);
export const categories = pgTable('categories', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
color: text('color').notNull(),
order: integer('order').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
export const tasks = pgTable('tasks', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
categoryId: text('category_id').notNull().references(() => categories.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
completed: boolean('completed').notNull().default(false),
dueDate: bigint('due_date', { mode: 'number' }).notNull().default(0),
dueTime: text('due_time').notNull().default(''),
endTime: text('end_time').notNull().default(''),
allDay: boolean('all_day').notNull().default(false),
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: integer('repeat_interval').notNull().default(1),
repeatDays: text('repeat_days').notNull().default(''),
seriesId: text('series_id').notNull().default(''),
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: text('reminders').notNull().default(''),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
export const repeatProfiles = pgTable('repeat_profiles', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: integer('repeat_interval').notNull().default(1),
repeatDays: text('repeat_days').notNull().default(''),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
export const subtasks = pgTable('subtasks', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
completed: boolean('completed').notNull().default(false),
dueDate: bigint('due_date', { mode: 'number' }).notNull().default(0),
dueTime: text('due_time').notNull().default(''),
endTime: text('end_time').notNull().default(''),
allDay: boolean('all_day').notNull().default(false),
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: integer('repeat_interval').notNull().default(1),
repeatDays: text('repeat_days').notNull().default(''),
seriesId: text('series_id').notNull().default(''),
reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: text('reminders').notNull().default(''),
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
order: integer('order').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
export const userSettings = pgTable('user_settings', {
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
darkMode: boolean('dark_mode').notNull().default(false),
notifications: boolean('notifications').notNull().default(true),
reminderTime: text('reminder_time').notNull().default('09:00'),
defaultCategory: text('default_category'),
sortBy: text('sort_by', { enum: ['dueDate', 'priority', 'title', 'createdAt'] }).notNull().default('dueDate'),
sortOrder: text('sort_order', { enum: ['asc', 'desc'] }).notNull().default('asc'),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
+49
View File
@@ -0,0 +1,49 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import authRoutes from './routes/auth';
import categoryRoutes from './routes/categories';
import taskRoutes from './routes/tasks';
import subtaskRoutes from './routes/subtasks';
import userRoutes from './routes/users';
import friendRoutes from './routes/friends';
import repeatProfileRoutes from './routes/repeatProfiles';
import syncRoutes from './routes/sync';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors({
origin: true,
credentials: true,
}));
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/categories', categoryRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/subtasks', subtaskRoutes);
app.use('/api/users', userRoutes);
app.use('/api/repeat-profiles', repeatProfileRoutes);
app.use('/api/sync', syncRoutes);
app.use('/api/friends', friendRoutes);
// 404 handler
app.use(notFoundHandler);
// Error handler
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
console.log(`📚 API available at http://localhost:${PORT}/api`);
});
export default app;
+56
View File
@@ -0,0 +1,56 @@
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
export class AppError extends Error {
constructor(
public code: string,
public message: string,
public statusCode: number = 500,
public details?: Record<string, any>
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction): void {
console.error('Error:', err);
if (err instanceof ZodError) {
res.status(400).json({
error: {
code: 'INVALID_PAYLOAD',
message: 'Request validation failed',
details: err.flatten().fieldErrors,
},
});
return;
}
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
return;
}
res.status(500).json({
error: {
code: 'SERVER_ERROR',
message: 'Internal server error',
},
});
}
export function notFoundHandler(req: Request, res: Response): void {
res.status(404).json({
error: {
code: 'NOT_FOUND',
message: `Route ${req.method} ${req.path} not found`,
},
});
}
+199
View File
@@ -0,0 +1,199 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
import { generateToken, generateId, getCurrentTimestamp } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import rateLimit from 'express-rate-limit';
import crypto from 'crypto';
const router = Router();
// Rate limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // limit each IP to 10 requests per windowMs
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // stricter limit for login
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many login attempts. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const passwordResetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // very strict for password reset
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many password reset requests. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const usernameSchema = z
.string()
.min(3)
.max(30)
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username can only contain letters, numbers, dots, dashes and underscores');
const registerSchema = z.object({
username: usernameSchema,
password: z.string().min(8),
});
const loginSchema = z.object({
username: z.string(),
password: z.string(),
});
const forgotPasswordSchema = z.object({
username: z.string().min(1),
});
const resetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8),
});
const BCRYPT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
function generateResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
router.post('/register', authLimiter, asyncHandler(async (req: Request, res: Response) => {
const data = registerSchema.parse(req.body);
const existing = await db.select().from(users).where(eq(users.username, data.username)).limit(1);
if (existing.length > 0) {
throw new AppError('USERNAME_EXISTS', 'Username already registered', 409);
}
const userId = generateId('user');
const now = getCurrentTimestamp();
await db.insert(users).values({
id: userId,
username: data.username,
passwordHash: await hashPassword(data.password),
createdAt: now,
});
const token = generateToken({ userId, username: data.username });
res.status(201).json({
user: { id: userId, username: data.username },
token,
});
}));
router.post('/login', loginLimiter, asyncHandler(async (req: Request, res: Response) => {
const data = loginSchema.parse(req.body);
const user = await db.select().from(users).where(eq(users.username, data.username)).limit(1);
if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) {
throw new AppError('INVALID_CREDENTIALS', 'Invalid username or password', 401);
}
const token = generateToken({ userId: user[0].id, username: user[0].username });
res.json({
user: { id: user[0].id, username: user[0].username },
token,
});
}));
router.get('/me', asyncHandler(async (req: Request, res: Response) => {
if (!req.user) {
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
}
const user = await db.select().from(users).where(eq(users.id, req.user.userId)).limit(1);
if (user.length === 0) {
throw new AppError('NOT_FOUND', 'User not found', 404);
}
res.json({ id: user[0].id, username: user[0].username });
}));
router.post('/forgot-password', passwordResetLimiter, asyncHandler(async (req: Request, res: Response) => {
const data = forgotPasswordSchema.parse(req.body);
const user = await db.select().from(users).where(eq(users.username, data.username)).limit(1);
// Always return success to prevent username enumeration
if (user.length === 0) {
res.json({ success: true });
return;
}
const resetToken = generateResetToken();
const resetTokenExpiry = Date.now() + 3600000; // 1 hour
await db
.update(users)
.set({ resetToken, resetTokenExpiry })
.where(eq(users.id, user[0].id));
// In production, send email with reset link
// For now, return token in response (dev only)
if (process.env.NODE_ENV !== 'production') {
res.json({ success: true, resetToken });
} else {
res.json({ success: true });
}
}));
router.post('/reset-password', passwordResetLimiter, asyncHandler(async (req: Request, res: Response) => {
const data = resetPasswordSchema.parse(req.body);
const user = await db
.select()
.from(users)
.where(eq(users.resetToken, data.token))
.limit(1);
if (user.length === 0 || !user[0].resetTokenExpiry || user[0].resetTokenExpiry < Date.now()) {
throw new AppError('INVALID_TOKEN', 'Invalid or expired reset token', 400);
}
const newPasswordHash = await hashPassword(data.password);
await db
.update(users)
.set({ passwordHash: newPasswordHash, resetToken: null, resetTokenExpiry: null })
.where(eq(users.id, user[0].id));
res.json({ success: true });
}));
export default router;
+95
View File
@@ -0,0 +1,95 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { categories } from '../db/schema';
import { eq, and, desc, asc } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { categoryCreateSchema, categoryUpdateSchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const userCategories = await db
.select()
.from(categories)
.where(eq(categories.userId, req.user!.userId))
.orderBy(asc(categories.order), asc(categories.createdAt));
res.json({ categories: userCategories });
}));
router.post('/', asyncHandler(async (req: Request, res: Response) => {
const data = categoryCreateSchema.parse(req.body);
const userId = req.user!.userId;
const now = Date.now();
const maxOrder = await db
.select({ order: categories.order })
.from(categories)
.where(eq(categories.userId, userId))
.orderBy(desc(categories.order))
.limit(1);
const newCategory = {
id: `cat_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
userId,
name: data.name,
color: data.color,
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
createdAt: now,
updatedAt: now,
};
await db.insert(categories).values(newCategory);
res.status(201).json(newCategory);
}));
router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = categoryUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
.select()
.from(categories)
.where(and(eq(categories.id, req.params.id), eq(categories.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
const now = Date.now();
const updated = await db
.update(categories)
.set({ ...data, updatedAt: now })
.where(and(eq(categories.id, req.params.id), eq(categories.userId, userId)))
.returning();
res.json(updated[0]);
}));
router.delete('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const existing = await db
.select()
.from(categories)
.where(and(eq(categories.id, req.params.id), eq(categories.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
await db
.delete(categories)
.where(and(eq(categories.id, req.params.id), eq(categories.userId, userId)));
res.status(204).send();
}));
export default router;
+194
View File
@@ -0,0 +1,194 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { users, friendships } from '../db/schema';
import { eq, and, or, ilike, ne, asc } from 'drizzle-orm';
import { generateId } from '../utils/auth';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { friendRequestSchema, searchQuerySchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
interface FriendRow {
id: string;
username: string;
requestId?: string;
status?: string;
}
async function findUsernames(ids: string[]): Promise<Map<string, string>> {
if (ids.length === 0) return new Map();
const rows = await db.select().from(users).where(or(...ids.map((id) => eq(users.id, id))));
return new Map(rows.map((u) => [u.id, u.username]));
}
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const outgoingRows = await db
.select()
.from(friendships)
.where(and(eq(friendships.userId, userId), eq(friendships.status, 'pending')));
const incomingRows = await db
.select()
.from(friendships)
.where(and(eq(friendships.friendId, userId), eq(friendships.status, 'pending')));
const acceptedRows = await db
.select()
.from(friendships)
.where(and(
or(eq(friendships.userId, userId), eq(friendships.friendId, userId)),
eq(friendships.status, 'accepted')
));
const friendIds = acceptedRows.map((r) => (r.userId === userId ? r.friendId : r.userId));
const outgoingIds = outgoingRows.map((r) => r.friendId);
const incomingIds = incomingRows.map((r) => r.userId);
const usernames = await findUsernames([...friendIds, ...outgoingIds, ...incomingIds]);
const friends: FriendRow[] = acceptedRows.map((r) => {
const friendId = r.userId === userId ? r.friendId : r.userId;
return { id: friendId, username: usernames.get(friendId) ?? '' };
});
const outgoing: FriendRow[] = outgoingRows.map((r) => ({
id: r.id,
username: usernames.get(r.friendId) ?? '',
requestId: r.id,
status: 'pending',
}));
const incoming: FriendRow[] = incomingRows.map((r) => ({
id: r.userId,
username: usernames.get(r.userId) ?? '',
requestId: r.id,
status: 'pending',
}));
res.json({ friends, incoming, outgoing });
}));
router.get('/search', asyncHandler(async (req: Request, res: Response) => {
const { q } = searchQuerySchema.parse(req.query);
const userId = req.user!.userId;
const results = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(and(
ne(users.id, userId),
ilike(users.username, `%${q}%`)
))
.orderBy(asc(users.username))
.limit(20);
res.json(results);
}));
router.post('/requests', asyncHandler(async (req: Request, res: Response) => {
const { username } = friendRequestSchema.parse(req.body);
const userId = req.user!.userId;
if (username.toLowerCase() === (await meUsername(userId))) {
throw new AppError('SELF_REQUEST', 'You cannot add yourself', 400);
}
const target = await db.select().from(users).where(eq(users.username, username)).limit(1);
if (target.length === 0) {
throw new AppError('USER_NOT_FOUND', 'No user with that username found', 404);
}
const existing = await db
.select()
.from(friendships)
.where(or(
and(eq(friendships.userId, userId), eq(friendships.friendId, target[0].id)),
and(eq(friendships.userId, target[0].id), eq(friendships.friendId, userId))
))
.limit(1);
if (existing.length > 0) {
throw new AppError(
'ALREADY_FRIENDS',
existing[0].status === 'accepted' ? 'You are already friends' : 'Friend request already pending',
409
);
}
const now = Date.now();
await db.insert(friendships).values({
id: generateId('friend'),
userId,
friendId: target[0].id,
status: 'pending',
createdAt: now,
updatedAt: now,
});
res.status(201).json({ success: true });
}));
router.post('/requests/:id/accept', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const requestId = req.params.id;
const row = await db.select().from(friendships).where(eq(friendships.id, requestId)).limit(1);
if (row.length === 0) {
throw new AppError('NOT_FOUND', 'Request not found', 404);
}
if (row[0].friendId !== userId) {
throw new AppError('FORBIDDEN', 'This request was not sent to you', 403);
}
if (row[0].status !== 'pending') {
throw new AppError('INVALID_STATE', 'Request is no longer pending', 409);
}
await db
.update(friendships)
.set({ status: 'accepted', updatedAt: Date.now() })
.where(eq(friendships.id, requestId));
res.json({ success: true });
}));
router.delete('/requests/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const requestId = req.params.id;
const row = await db.select().from(friendships).where(eq(friendships.id, requestId)).limit(1);
if (row.length === 0) {
throw new AppError('NOT_FOUND', 'Request not found', 404);
}
if (row[0].userId !== userId && row[0].friendId !== userId) {
throw new AppError('FORBIDDEN', 'Not allowed', 403);
}
await db.delete(friendships).where(eq(friendships.id, requestId));
res.json({ success: true });
}));
router.delete('/:friendId', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const friendId = req.params.friendId;
await db
.delete(friendships)
.where(or(
and(eq(friendships.userId, userId), eq(friendships.friendId, friendId)),
and(eq(friendships.userId, friendId), eq(friendships.friendId, userId))
));
res.json({ success: true });
}));
async function meUsername(userId: string): Promise<string> {
const me = await db.select().from(users).where(eq(users.id, userId)).limit(1);
return me.length > 0 ? me[0].username.toLowerCase() : '';
}
export default router;
+90
View File
@@ -0,0 +1,90 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { repeatProfiles } from '../db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { generateId } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { repeatProfileSchema, repeatProfileUpdateSchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const profiles = await db
.select()
.from(repeatProfiles)
.where(eq(repeatProfiles.userId, req.user!.userId))
.orderBy(asc(repeatProfiles.createdAt));
res.json({ profiles });
}));
router.post('/', asyncHandler(async (req: Request, res: Response) => {
const data = repeatProfileSchema.parse(req.body);
const userId = req.user!.userId;
const now = Date.now();
const newProfile = {
id: generateId('rp'),
userId,
name: data.name,
repeat: data.repeat,
repeatInterval: data.repeatInterval,
repeatDays: data.repeatDays,
createdAt: now,
updatedAt: now,
};
await db.insert(repeatProfiles).values(newProfile);
res.status(201).json(newProfile);
}));
router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = repeatProfileUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.id, req.params.id), eq(repeatProfiles.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Repeat profile not found', 404);
}
const now = Date.now();
const updated = await db
.update(repeatProfiles)
.set({ ...data, updatedAt: now })
.where(and(eq(repeatProfiles.id, req.params.id), eq(repeatProfiles.userId, userId)))
.returning();
res.json(updated[0]);
}));
router.delete('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const existing = await db
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.id, req.params.id), eq(repeatProfiles.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Repeat profile not found', 404);
}
await db
.delete(repeatProfiles)
.where(and(eq(repeatProfiles.id, req.params.id), eq(repeatProfiles.userId, userId)));
res.status(204).send();
}));
export default router;
+154
View File
@@ -0,0 +1,154 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { subtasks, tasks } from '../db/schema';
import { eq, and, asc, desc } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
// Verify task exists and belongs to user
const task = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.taskId), eq(tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
const taskSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.json({ subtasks: taskSubtasks });
}));
router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
const data = subtaskCreateSchema.parse(req.body);
const userId = req.user!.userId;
const task = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.taskId), eq(tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
const now = Date.now();
const maxOrder = await db
.select({ order: subtasks.order })
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
.orderBy(desc(subtasks.order))
.limit(1);
const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newSubtask = {
id: subtaskId,
userId,
taskId: req.params.taskId,
title: data.title,
description: data.description ?? '',
priority: data.priority ?? 'none',
completed: false,
dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '',
allDay: data.allDay ?? false,
repeat: data.repeat ?? 'none',
repeatInterval: data.repeatInterval ?? 1,
repeatDays: data.repeatDays ?? '',
seriesId: data.seriesId ?? '',
reminder: data.reminder ?? 'none',
reminders: data.reminders ?? '',
assigneeId: data.assigneeId ?? null,
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
createdAt: now,
updatedAt: now,
};
await db.insert(subtasks).values(newSubtask);
// Update task updatedAt
await db
.update(tasks)
.set({ updatedAt: now })
.where(and(eq(tasks.id, req.params.taskId), eq(tasks.userId, userId)));
res.status(201).json(newSubtask);
}));
router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = subtaskUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Subtask not found', 404);
}
const now = Date.now();
const updated = await db
.update(subtasks)
.set({ ...data, updatedAt: now })
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
.returning();
// Update task updatedAt
await db
.update(tasks)
.set({ updatedAt: now })
.where(and(eq(tasks.id, existing[0].taskId), eq(tasks.userId, userId)));
res.json(updated[0]);
}));
router.delete('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const existing = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Subtask not found', 404);
}
const taskId = existing[0].taskId;
await db
.delete(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)));
// Update task updatedAt
await db
.update(tasks)
.set({ updatedAt: Date.now() })
.where(and(eq(tasks.id, taskId), eq(tasks.userId, userId)));
res.status(204).send();
}));
export default router;
+381
View File
@@ -0,0 +1,381 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { categories, tasks, subtasks, repeatProfiles, users, friendships } from '../db/schema';
import { eq, and, gte, lte, asc, or, inArray, sql } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const { since } = syncQuerySchema.parse(req.query);
const userId = req.user!.userId;
const sinceDate = since;
// Fetch categories changed since timestamp
const changedCategories = await db
.select()
.from(categories)
.where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate)))
.orderBy(asc(categories.updatedAt));
// Fetch tasks changed since timestamp
const changedTasks = await db
.select()
.from(tasks)
.where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate)))
.orderBy(asc(tasks.updatedAt));
// Fetch subtasks changed since timestamp
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks: any[] = [];
if (taskIds.length > 0) {
changedSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt));
} else {
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt)
changedSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt));
}
// Fetch repeat profiles changed since timestamp
const changedRepeatProfiles = await db
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate)))
.orderBy(asc(repeatProfiles.updatedAt));
// Fetch friendships changed since timestamp
const changedFriendships = await db
.select()
.from(friendships)
.where(and(
or(eq(friendships.userId, userId), eq(friendships.friendId, userId)),
gte(friendships.updatedAt, sinceDate)
))
.orderBy(asc(friendships.updatedAt));
const timestamp = Date.now();
res.json({
categories: changedCategories,
tasks: changedTasks,
subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships,
timestamp,
});
}));
router.post('/push', asyncHandler(async (req: Request, res: Response) => {
const data = pushChangesSchema.parse(req.body);
const userId = req.user!.userId;
const conflicts: any[] = [];
const timestamp = Date.now();
try {
await db.transaction(async (tx) => {
// Ensure every referenced category exists (FK integrity) so a stale or
// never-synced category reference cannot fail the entire push. Missing
// categories are recreated as a fallback and the client heals on pull.
const referencedCategoryIds = new Set<string>();
for (const task of data.changes.tasks ?? []) {
if (task.categoryId) referencedCategoryIds.add(task.categoryId);
}
for (const sub of data.changes.subtasks ?? []) {
const task = (data.changes.tasks ?? []).find((t) => t.id === sub.taskId);
if (task?.categoryId) referencedCategoryIds.add(task.categoryId);
}
if (referencedCategoryIds.size > 0) {
const existing = await tx
.select({ id: categories.id })
.from(categories)
.where(inArray(categories.id, [...referencedCategoryIds]));
const existingIds = new Set(existing.map((c) => c.id));
const missing = [...referencedCategoryIds].filter((id) => !existingIds.has(id));
if (missing.length > 0) {
const rows = await tx
.select({ max: sql<number>`max(${categories.order})` })
.from(categories)
.where(eq(categories.userId, userId));
const startOrder = (rows[0]?.max ?? -1) + 1;
await tx.insert(categories).values(
missing.map((id, i) => ({
id,
userId,
name: 'Default',
color: '#9E9E9E',
order: startOrder + i,
createdAt: Date.now(),
updatedAt: Date.now(),
}))
);
}
}
// Process categories
if (data.changes.categories && data.changes.categories.length > 0) {
for (const cat of data.changes.categories) {
const existing = await tx
.select()
.from(categories)
.where(and(eq(categories.id, cat.id), eq(categories.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > cat.updatedAt) {
conflicts.push({
entity: 'categories',
id: cat.id,
serverVersion: existing[0],
clientVersion: cat,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(categories)
.set({
name: cat.name,
color: cat.color,
order: cat.order,
updatedAt: cat.updatedAt,
})
.where(and(eq(categories.id, cat.id), eq(categories.userId, userId)));
} else {
await tx.insert(categories).values({
...cat,
userId,
});
}
}
}
// Process tasks
if (data.changes.tasks && data.changes.tasks.length > 0) {
for (const task of data.changes.tasks) {
const existing = await tx
.select()
.from(tasks)
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > task.updatedAt) {
conflicts.push({
entity: 'tasks',
id: task.id,
serverVersion: existing[0],
clientVersion: task,
resolution: 'server_wins',
});
continue; // Server wins
}
// Prevent completing tasks with future due dates
if (task.completed === true) {
const effectiveDueDate = task.dueDate ?? existing[0].dueDate;
if (!canCompleteTask(effectiveDueDate)) {
conflicts.push({
entity: 'tasks',
id: task.id,
serverVersion: existing[0],
clientVersion: task,
resolution: 'server_wins',
});
continue;
}
}
await tx
.update(tasks)
.set({
title: task.title,
description: task.description,
categoryId: task.categoryId,
priority: task.priority,
completed: task.completed,
dueDate: task.dueDate,
dueTime: task.dueTime ?? '',
endTime: task.endTime ?? '',
allDay: task.allDay ?? false,
repeat: task.repeat ?? 'none',
repeatInterval: task.repeatInterval ?? 1,
repeatDays: task.repeatDays ?? '',
seriesId: task.seriesId ?? '',
reminder: task.reminder ?? 'none',
reminders: task.reminders ?? '',
assigneeId: task.assigneeId ?? null,
updatedAt: task.updatedAt,
})
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
} else {
await tx.insert(tasks).values({
...task,
allDay: task.allDay ?? false,
userId,
});
}
}
}
// Process subtasks
if (data.changes.subtasks && data.changes.subtasks.length > 0) {
for (const sub of data.changes.subtasks) {
const existing = await tx
.select()
.from(subtasks)
.where(and(eq(subtasks.id, sub.id), eq(subtasks.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > sub.updatedAt) {
conflicts.push({
entity: 'subtasks',
id: sub.id,
serverVersion: existing[0],
clientVersion: sub,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(subtasks)
.set({
taskId: sub.taskId,
title: sub.title,
description: sub.description ?? '',
priority: sub.priority ?? 'none',
completed: sub.completed,
dueDate: sub.dueDate ?? 0,
dueTime: sub.dueTime ?? '',
endTime: sub.endTime ?? '',
allDay: sub.allDay ?? false,
repeat: sub.repeat ?? 'none',
repeatInterval: sub.repeatInterval ?? 1,
repeatDays: sub.repeatDays ?? '',
seriesId: sub.seriesId ?? '',
reminder: sub.reminder ?? 'none',
reminders: sub.reminders ?? '',
assigneeId: sub.assigneeId ?? null,
order: sub.order,
updatedAt: sub.updatedAt,
})
.where(and(eq(subtasks.id, sub.id), eq(subtasks.userId, userId)));
} else {
await tx.insert(subtasks).values({
...sub,
userId,
});
}
}
}
// Process repeat profiles
if (data.changes.repeatProfiles && data.changes.repeatProfiles.length > 0) {
for (const profile of data.changes.repeatProfiles) {
const existing = await tx
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.id, profile.id), eq(repeatProfiles.userId, userId)))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > profile.updatedAt) {
conflicts.push({
entity: 'repeatProfiles',
id: profile.id,
serverVersion: existing[0],
clientVersion: profile,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(repeatProfiles)
.set({
name: profile.name,
repeat: profile.repeat,
repeatInterval: profile.repeatInterval,
repeatDays: profile.repeatDays,
updatedAt: profile.updatedAt,
})
.where(and(eq(repeatProfiles.id, profile.id), eq(repeatProfiles.userId, userId)));
} else {
await tx.insert(repeatProfiles).values({
...profile,
userId,
});
}
}
}
// Process friendships
if (data.changes.friendships && data.changes.friendships.length > 0) {
for (const friendship of data.changes.friendships) {
const existing = await tx
.select()
.from(friendships)
.where(eq(friendships.id, friendship.id))
.limit(1);
if (existing.length > 0) {
// Check for conflict
if (existing[0].updatedAt > friendship.updatedAt) {
conflicts.push({
entity: 'friendships',
id: friendship.id,
serverVersion: existing[0],
clientVersion: friendship,
resolution: 'server_wins',
});
continue; // Server wins
}
await tx
.update(friendships)
.set({
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
updatedAt: friendship.updatedAt,
})
.where(eq(friendships.id, friendship.id));
} else {
await tx.insert(friendships).values(friendship);
}
}
}
});
} catch (error) {
console.error('Sync push error:', error);
throw new AppError('SERVER_ERROR', 'Failed to process sync push', 500);
}
res.json({
success: true,
timestamp,
conflicts,
});
}));
export default router;
+326
View File
@@ -0,0 +1,326 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { tasks, subtasks, categories } from '../db/schema';
import { eq, and, desc, asc, gte, lte, inArray, sql } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { taskCreateSchema, taskUpdateSchema, taskQuerySchema, canCompleteTask } from '../utils/validation';
import { z } from 'zod';
const router = Router();
router.use(authMiddleware);
function applyTaskFilters(query: any, userId: string, filters: any) {
const conditions = [eq(tasks.userId, userId)];
if (filters.categoryId) {
conditions.push(eq(tasks.categoryId, filters.categoryId));
}
if (filters.completed !== undefined) {
conditions.push(eq(tasks.completed, filters.completed));
}
if (filters.dueBefore) {
conditions.push(lte(tasks.dueDate, filters.dueBefore));
}
if (filters.dueAfter) {
conditions.push(gte(tasks.dueDate, filters.dueAfter));
}
if (filters.priority) {
conditions.push(eq(tasks.priority, filters.priority));
}
return query.where(and(...conditions));
}
function applyTaskSorting(query: any, sortBy: string = 'dueDate', sortOrder: string = 'asc') {
const orderFn = sortOrder === 'desc' ? desc : asc;
const columnMap: Record<string, any> = {
dueDate: tasks.dueDate,
priority: tasks.priority,
createdAt: tasks.createdAt,
title: tasks.title,
};
return query.orderBy(orderFn(columnMap[sortBy] || tasks.dueDate));
}
router.get('/', asyncHandler(async (req: Request, res: Response) => {
const filters = taskQuerySchema.parse(req.query);
const userId = req.user!.userId;
let query: any = db.select().from(tasks);
query = applyTaskFilters(query, userId, filters);
query = applyTaskSorting(query, filters.sortBy, filters.sortOrder);
const limit = filters.limit ?? 50;
const offset = filters.offset ?? 0;
query = query.limit(limit).offset(offset);
const results = await query;
// Fetch subtasks for each task
const taskIds = results.map((t: { id: string }) => t.id);
let taskSubtasks: any[] = [];
if (taskIds.length > 0) {
taskSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.userId, userId), inArray(subtasks.taskId, taskIds)))
.orderBy(asc(subtasks.order));
}
const subtasksByTask = taskSubtasks.reduce((acc, st) => {
if (!acc[st.taskId]) acc[st.taskId] = [];
acc[st.taskId].push(st);
return acc;
}, {} as Record<string, any[]>);
const tasksWithSubtasks = results.map((task: any) => ({
...task,
subtasks: subtasksByTask[task.id] || [],
}));
// Get total count
const countQuery: any = db.select({ count: sql`count(*)` }).from(tasks);
const countResult = await applyTaskFilters(countQuery, userId, filters);
const total = Number(countResult[0]?.count ?? 0);
res.json({ tasks: tasksWithSubtasks, total, limit, offset });
}));
router.get('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const task = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)))
.limit(1);
if (task.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
const taskSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.id), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.json({ ...task[0], subtasks: taskSubtasks });
}));
router.post('/', asyncHandler(async (req: Request, res: Response) => {
const data = taskCreateSchema.parse(req.body);
const userId = req.user!.userId;
const now = Date.now();
// Verify category exists and belongs to user
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newTask = {
id: taskId,
userId,
categoryId: data.categoryId,
title: data.title,
description: data.description ?? '',
priority: data.priority ?? 'none',
completed: false,
dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '',
allDay: data.allDay ?? false,
assigneeId: data.assigneeId ?? null,
reminder: data.reminder ?? 'none',
reminders: data.reminders ?? '',
createdAt: now,
updatedAt: now,
};
await db.insert(tasks).values(newTask);
// Create subtasks if provided
if (data.subtasks && data.subtasks.length > 0) {
const subtaskValues = data.subtasks.map((st, index) => ({
id: `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}${index}`,
userId,
taskId,
title: st.title,
completed: false,
order: index,
createdAt: now,
updatedAt: now,
}));
await db.insert(subtasks).values(subtaskValues);
}
const createdSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.taskId, taskId), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.status(201).json({ ...newTask, subtasks: createdSubtasks });
}));
router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = taskUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
// Verify category if provided
if (data.categoryId) {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
}
// Prevent completing tasks with future due dates
if (data.completed === true) {
const effectiveDueDate = data.dueDate ?? existing[0].dueDate;
if (!canCompleteTask(effectiveDueDate)) {
throw new AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
}
}
const now = Date.now();
const updated = await db
.update(tasks)
.set({ ...data, updatedAt: now })
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)))
.returning();
const taskSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.id), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.json({ ...updated[0], subtasks: taskSubtasks });
}));
router.delete('/:id', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const existing = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new AppError('NOT_FOUND', 'Task not found', 404);
}
await db
.delete(tasks)
.where(and(eq(tasks.id, req.params.id), eq(tasks.userId, userId)));
res.status(204).send();
}));
router.post('/batch', asyncHandler(async (req: Request, res: Response) => {
const { operations } = z.object({
operations: z.array(
z.union([
z.object({ type: z.literal('create'), data: taskCreateSchema }),
z.object({ type: z.literal('update'), id: z.string(), data: taskUpdateSchema }),
z.object({ type: z.literal('delete'), id: z.string() }),
])
),
}).parse(req.body);
const userId = req.user!.userId;
const results: any[] = [];
for (const op of operations) {
try {
if (op.type === 'create') {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const now = Date.now();
await db.insert(tasks).values({
id: taskId,
userId,
...op.data,
completed: false,
createdAt: now,
updatedAt: now,
});
results.push({ id: taskId, success: true });
} else if (op.type === 'update') {
if (op.data.completed === true) {
let effectiveDueDate: number;
if (op.data.dueDate !== undefined) {
effectiveDueDate = op.data.dueDate;
} else {
const existingTask = await db
.select({ dueDate: tasks.dueDate })
.from(tasks)
.where(and(eq(tasks.id, op.id), eq(tasks.userId, userId)))
.limit(1);
if (existingTask.length === 0) throw new AppError('NOT_FOUND', 'Task not found', 404);
effectiveDueDate = existingTask[0].dueDate;
}
if (!canCompleteTask(effectiveDueDate)) {
throw new AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
}
}
await db
.update(tasks)
.set({ ...op.data, updatedAt: Date.now() })
.where(and(eq(tasks.id, op.id), eq(tasks.userId, userId)));
results.push({ id: op.id, success: true });
} else if (op.type === 'delete') {
await db
.delete(tasks)
.where(and(eq(tasks.id, op.id), eq(tasks.userId, userId)));
results.push({ id: op.id, success: true });
}
} catch (error) {
results.push({
id: 'id' in op ? op.id : 'unknown',
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
res.json({ results });
}));
export default router;
+111
View File
@@ -0,0 +1,111 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { users, userSettings, categories } from '../db/schema';
import { eq, and, ilike } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { userSettingsSchema } from '../utils/validation';
const router = Router();
router.use(authMiddleware);
router.get('/me', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user.length === 0) {
throw new AppError('NOT_FOUND', 'User not found', 404);
}
const settings = await db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
let defaultCategory = settings[0]?.defaultCategory;
if (defaultCategory) {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, defaultCategory), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
defaultCategory = null;
}
}
res.json({
id: user[0].id,
username: user[0].username,
settings: {
darkMode: settings[0]?.darkMode ?? false,
notifications: settings[0]?.notifications ?? true,
reminderTime: settings[0]?.reminderTime ?? '09:00',
defaultCategory,
sortBy: settings[0]?.sortBy ?? 'dueDate',
sortOrder: settings[0]?.sortOrder ?? 'asc',
},
createdAt: user[0].createdAt,
});
}));
router.patch('/me/settings', asyncHandler(async (req: Request, res: Response) => {
const data = userSettingsSchema.parse(req.body);
const userId = req.user!.userId;
// Validate defaultCategory if provided
if (data.defaultCategory) {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, data.defaultCategory), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Default category not found', 404);
}
}
const now = Date.now();
const existing = await db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
if (existing.length === 0) {
await db.insert(userSettings).values({
userId,
...data,
updatedAt: now,
});
} else {
await db
.update(userSettings)
.set({ ...data, updatedAt: now })
.where(eq(userSettings.userId, userId));
}
res.status(200).json({ success: true });
}));
router.get('/search', asyncHandler(async (req: Request, res: Response) => {
const { q } = req.query;
const userId = req.user!.userId;
if (!q || typeof q !== 'string' || q.length < 2) {
return res.json([]);
}
const results = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(and(ilike(users.username, `%${q}%`), eq(users.id, userId)))
.limit(10);
res.json(results);
}));
export default router;
+91
View File
@@ -0,0 +1,91 @@
export interface Category {
id: string;
name: string;
color: string;
order: number;
createdAt: number;
updatedAt: number;
}
export interface Task {
id: string;
title: string;
description: string;
categoryId: string;
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
completed: boolean;
dueDate: number;
dueTime: string;
endTime: string;
assigneeId: string | null;
createdAt: number;
updatedAt: number;
subtasks?: Subtask[];
}
export interface Subtask {
id: string;
taskId: string;
title: string;
completed: boolean;
order: number;
createdAt: number;
updatedAt: number;
}
export interface UserSettings {
darkMode: boolean;
notifications: boolean;
reminderTime: string;
defaultCategory: string | null;
sortBy: 'dueDate' | 'priority' | 'title' | 'createdAt';
sortOrder: 'asc' | 'desc';
}
export interface User {
id: string;
username: string;
settings: UserSettings;
createdAt: number;
}
export interface SyncResponse {
categories: Category[];
tasks: Task[];
subtasks: Subtask[];
timestamp: number;
}
export interface PushChangesRequest {
changes: {
categories: Category[];
tasks: Task[];
subtasks: Subtask[];
};
lastPulledAt: number;
}
export interface PushChangesResponse {
success: boolean;
timestamp: number;
conflicts: Array<{
entity: string;
id: string;
serverVersion: any;
clientVersion: any;
resolution: 'server_wins' | 'client_wins' | 'merge';
}>;
}
export interface AuthPayload {
userId: string;
username: string;
}
declare global {
namespace Express {
interface Request {
user?: AuthPayload;
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Request, Response, NextFunction } from 'express';
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
export function asyncHandler(handler: AsyncHandler) {
return (req: Request, res: Response, next: NextFunction): void => {
handler(req, res, next).catch(next);
};
}
+85
View File
@@ -0,0 +1,85 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
import { AuthPayload } from '../types';
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
const JWT_EXPIRES_IN = '7d';
export function generateToken(payload: AuthPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
export function verifyToken(token: string): AuthPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as AuthPayload;
} catch {
return null;
}
}
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Missing or invalid authorization header',
},
});
return;
}
const token = authHeader.slice(7);
const payload = verifyToken(token);
if (!payload) {
res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid or expired token',
},
});
return;
}
const user = await db.select({ id: users.id }).from(users).where(eq(users.id, payload.userId)).limit(1);
if (user.length === 0) {
res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'User no longer exists, please log in again',
},
});
return;
}
req.user = payload;
next();
}
export function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const payload = verifyToken(token);
if (payload) {
req.user = payload;
}
}
next();
}
export function generateId(prefix: string = ''): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).slice(2, 10);
return `${prefix}${prefix ? '_' : ''}${timestamp}${random}`;
}
export function getCurrentTimestamp(): number {
return Date.now();
}
+158
View File
@@ -0,0 +1,158 @@
import { z } from 'zod';
export function canCompleteTask(dueDate: number): boolean {
if (dueDate === 0) return true;
const now = new Date();
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
return dueDate <= endOfToday;
}
export const categoryCreateSchema = z.object({
name: z.string().min(1).max(50),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
order: z.number().int().min(0).optional(),
});
export const categoryUpdateSchema = z.object({
name: z.string().min(1).max(50).optional(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
order: z.number().int().min(0).optional(),
});
export const repeatSchema = z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
export const taskCreateSchema = z.object({
title: z.string().min(1).max(100),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
allDay: z.boolean().optional(),
repeat: repeatSchema.optional(),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(),
});
export const taskUpdateSchema = z.object({
title: z.string().min(1).max(100).optional(),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: z.boolean().optional(),
dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v === '' ? undefined : v),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(),
});
export const subtaskCreateSchema = z.object({
title: z.string().min(1).max(100),
description: z.string().max(1000).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
allDay: z.boolean().optional(),
repeat: repeatSchema.optional(),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(),
order: z.number().int().min(0).optional(),
});
export const subtaskUpdateSchema = z.object({
title: z.string().min(1).max(100).optional(),
description: z.string().max(1000).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: z.boolean().optional(),
dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v === '' ? undefined : v),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
allDay: z.boolean().optional(),
repeat: repeatSchema.optional(),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(),
order: z.number().int().min(0).optional(),
});
export const userSettingsSchema = z.object({
darkMode: z.boolean().optional(),
notifications: z.boolean().optional(),
reminderTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
defaultCategory: z.string().optional().nullable(),
sortBy: z.enum(['dueDate', 'priority', 'title', 'createdAt']).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
});
export const repeatProfileSchema = z.object({
name: z.string().min(1).max(50),
repeat: repeatSchema,
repeatInterval: z.number().int().min(1).max(30),
repeatDays: z.string().max(20),
});
export const repeatProfileUpdateSchema = z.object({
name: z.string().min(1).max(50).optional(),
repeat: repeatSchema.optional(),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.string().max(20).optional(),
});
export const searchQuerySchema = z.object({
q: z.string().min(1).max(50),
});
export const friendRequestSchema = z.object({
username: z.string().min(1).max(50),
});
export const friendshipSchema = z.object({
id: z.string(),
userId: z.string(),
friendId: z.string(),
status: z.enum(['pending', 'accepted']),
createdAt: z.number(),
updatedAt: z.number(),
});
export const pushChangesSchema = z.object({
changes: z.object({
categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
subtasks: z.array(subtaskCreateSchema.extend({ id: z.string(), taskId: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
friendships: z.array(friendshipSchema).optional(),
}),
lastPulledAt: z.number().int().min(0),
});
export const syncQuerySchema = z.object({
since: z.string().transform(Number).pipe(z.number().int().min(0)),
});
export const taskQuerySchema = z.object({
categoryId: z.string().optional(),
completed: z.string().transform(v => v === 'true').optional(),
dueBefore: z.string().transform(Number).pipe(z.number().int().min(0)).optional(),
dueAfter: z.string().transform(Number).pipe(z.number().int().min(0)).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
sortBy: z.enum(['dueDate', 'priority', 'createdAt', 'title']).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)).optional(),
offset: z.string().transform(Number).pipe(z.number().int().min(0)).optional(),
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+15
View File
@@ -0,0 +1,15 @@
# API Configuration
EXPO_PUBLIC_API_URL=http://localhost:3000/api
# Optional: Analytics & Monitoring
# EXPO_PUBLIC_SENTRY_DSN=
# EXPO_PUBLIC_AMPLITUDE_KEY=
# EXPO_PUBLIC_POSTHOG_KEY=
# Optional: Feature Flags
# EXPO_PUBLIC_ENABLE_SYNC=true
# EXPO_PUBLIC_ENABLE_NOTIFICATIONS=true
# Optional: Build Configuration
# EXPO_PUBLIC_APP_VERSION=1.0.0
# EXPO_PUBLIC_BUILD_NUMBER=1
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+26
View File
@@ -0,0 +1,26 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"plugins": [],
"overrides": [
{
"files": "*.md",
"options": {
"printWidth": 120,
"proseWrap": "always"
}
},
{
"files": "*.json",
"options": {
"printWidth": 200
}
}
]
}
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.

Some files were not shown because too many files have changed in this diff Show More