This commit is contained in:
@@ -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.
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
+646
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
Vendored
+4
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+45
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+994
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+83
@@ -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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
declare const app: import("express-serve-static-core").Express;
|
||||||
|
export default app;
|
||||||
|
//# sourceMappingURL=index.d.ts.map
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+45
@@ -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
|
||||||
Vendored
+1
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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"}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
declare const router: import("express-serve-static-core").Router;
|
||||||
|
export default router;
|
||||||
|
//# sourceMappingURL=auth.d.ts.map
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+76
@@ -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
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,wDAAqD;AACrD,8BAA2B;AAC3B,yCAAqC;AACrC,6CAAiC;AACjC,wCAA+E;AAC/E,6DAAsD;AACtD,6BAAwB;AAExB,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,cAAc,GAAG,OAAC;KACrB,MAAM,EAAE;KACR,GAAG,CAAC,CAAC,CAAC;KACN,GAAG,CAAC,EAAE,CAAC;KACP,KAAK,CAAC,mBAAmB,EAAE,0EAA0E,CAAC,CAAC;AAE1G,MAAM,cAAc,GAAG,OAAC,CAAC,MAAM,CAAC;IAC9B,QAAQ,EAAE,cAAc;IACxB,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CAC5B,CAAC,CAAC;AAEH,MAAM,WAAW,GAAG,OAAC,CAAC,MAAM,CAAC;IAC3B,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE;IACpB,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE;CACrB,CAAC,CAAC;AAEH,2DAA2D;AAC3D,SAAS,YAAY,CAAC,QAAgB;IACpC,2DAA2D;IAC3D,OAAO,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAClD,CAAC;AAED,SAAS,cAAc,CAAC,QAAgB,EAAE,IAAY;IACpD,OAAO,YAAY,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC;AACzC,CAAC;AAED,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IAC1E,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAE5C,MAAM,QAAQ,GAAG,MAAM,OAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,cAAK,CAAC,CAAC,KAAK,CAAC,IAAA,gBAAE,EAAC,cAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACjG,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,MAAM,IAAI,uBAAQ,CAAC,iBAAiB,EAAE,6BAA6B,EAAE,GAAG,CAAC,CAAC;IAC5E,CAAC;IAED,MAAM,MAAM,GAAG,IAAA,iBAAU,EAAC,MAAM,CAAC,CAAC;IAClC,MAAM,GAAG,GAAG,IAAA,0BAAmB,GAAE,CAAC;IAElC,MAAM,OAAE,CAAC,MAAM,CAAC,cAAK,CAAC,CAAC,MAAM,CAAC;QAC5B,EAAE,EAAE,MAAM;QACV,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,YAAY,EAAE,YAAY,CAAC,IAAI,CAAC,QAAQ,CAAC;QACzC,SAAS,EAAE,GAAG;KACf,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,IAAA,oBAAa,EAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IAEjE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;QACnB,IAAI,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE;QAC7C,KAAK;KACN,CAAC,CAAC;AACL,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACvE,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAEzC,MAAM,IAAI,GAAG,MAAM,OAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,cAAK,CAAC,CAAC,KAAK,CAAC,IAAA,gBAAE,EAAC,cAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC7F,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,EAAE,CAAC;QAC9E,MAAM,IAAI,uBAAQ,CAAC,qBAAqB,EAAE,8BAA8B,EAAE,GAAG,CAAC,CAAC;IACjF,CAAC;IAED,MAAM,KAAK,GAAG,IAAA,oBAAa,EAAC,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;IAEhF,GAAG,CAAC,IAAI,CAAC;QACP,IAAI,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;QACpD,KAAK;KACN,CAAC,CAAC;AACL,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACnE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QACd,MAAM,IAAI,uBAAQ,CAAC,cAAc,EAAE,yBAAyB,EAAE,GAAG,CAAC,CAAC;IACrE,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,OAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,cAAK,CAAC,CAAC,KAAK,CAAC,IAAA,gBAAE,EAAC,cAAK,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACzF,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,gBAAgB,EAAE,GAAG,CAAC,CAAC;IACzD,CAAC;IAED,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;AAC3D,CAAC,CAAC,CAAC,CAAC;AAEJ,kBAAe,MAAM,CAAC"}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
declare const router: import("express-serve-static-core").Router;
|
||||||
|
export default router;
|
||||||
|
//# sourceMappingURL=categories.d.ts.map
|
||||||
+1
@@ -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"}
|
||||||
Vendored
+78
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"categories.js","sourceRoot":"","sources":["../../src/routes/categories.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,wDAAqD;AACrD,8BAA2B;AAC3B,yCAA0C;AAC1C,6CAAiD;AACjD,wCAA+C;AAC/C,6DAAsD;AACtD,oDAAiF;AAEjF,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,qBAAc,CAAC,CAAC;AAE3B,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACjE,MAAM,cAAc,GAAG,MAAM,OAAE;SAC5B,MAAM,EAAE;SACR,IAAI,CAAC,mBAAU,CAAC;SAChB,KAAK,CAAC,IAAA,gBAAE,EAAC,mBAAU,CAAC,MAAM,EAAE,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC,CAAC;SAC9C,OAAO,CAAC,IAAA,iBAAG,EAAC,mBAAU,CAAC,KAAK,CAAC,EAAE,IAAA,iBAAG,EAAC,mBAAU,CAAC,SAAS,CAAC,CAAC,CAAC;IAE7D,GAAG,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,cAAc,EAAE,CAAC,CAAC;AAC3C,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IAClE,MAAM,IAAI,GAAG,iCAAoB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAClD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAChC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAEvB,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,CAAC,EAAE,KAAK,EAAE,mBAAU,CAAC,KAAK,EAAE,CAAC;SACnC,IAAI,CAAC,mBAAU,CAAC;SAChB,KAAK,CAAC,IAAA,gBAAE,EAAC,mBAAU,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;SACpC,OAAO,CAAC,IAAA,kBAAI,EAAC,mBAAU,CAAC,KAAK,CAAC,CAAC;SAC/B,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,MAAM,WAAW,GAAG;QAClB,EAAE,EAAE,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE;QAC7E,MAAM;QACN,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;QACnD,SAAS,EAAE,GAAG;QACd,SAAS,EAAE,GAAG;KACf,CAAC;IAEF,MAAM,OAAE,CAAC,MAAM,CAAC,mBAAU,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;IAEhD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;AACpC,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACtE,MAAM,IAAI,GAAG,iCAAoB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAClD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,mBAAU,CAAC;SAChB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,mBAAU,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,mBAAU,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SAC3E,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,oBAAoB,EAAE,GAAG,CAAC,CAAC;IAC7D,CAAC;IAED,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,OAAO,GAAG,MAAM,OAAE;SACrB,MAAM,CAAC,mBAAU,CAAC;SAClB,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC;SAChC,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,mBAAU,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,mBAAU,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SAC3E,SAAS,EAAE,CAAC;IAEf,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACvE,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,mBAAU,CAAC;SAChB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,mBAAU,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,mBAAU,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SAC3E,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,oBAAoB,EAAE,GAAG,CAAC,CAAC;IAC7D,CAAC;IAED,MAAM,OAAE;SACL,MAAM,CAAC,mBAAU,CAAC;SAClB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,mBAAU,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,mBAAU,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAE/E,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AACzB,CAAC,CAAC,CAAC,CAAC;AAEJ,kBAAe,MAAM,CAAC"}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
declare const router: import("express-serve-static-core").Router;
|
||||||
|
export default router;
|
||||||
|
//# sourceMappingURL=friends.d.ts.map
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+130
@@ -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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
declare const router: import("express-serve-static-core").Router;
|
||||||
|
export default router;
|
||||||
|
//# sourceMappingURL=subtasks.d.ts.map
|
||||||
+1
@@ -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"}
|
||||||
Vendored
+114
@@ -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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
declare const router: import("express-serve-static-core").Router;
|
||||||
|
export default router;
|
||||||
|
//# sourceMappingURL=sync.d.ts.map
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+289
@@ -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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
declare const router: import("express-serve-static-core").Router;
|
||||||
|
export default router;
|
||||||
|
//# sourceMappingURL=tasks.d.ts.map
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+254
@@ -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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
declare const router: import("express-serve-static-core").Router;
|
||||||
|
export default router;
|
||||||
|
//# sourceMappingURL=users.d.ts.map
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+98
@@ -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
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"users.js","sourceRoot":"","sources":["../../src/routes/users.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,wDAAqD;AACrD,8BAA2B;AAC3B,yCAA+D;AAC/D,6CAA6C;AAC7C,wCAA+C;AAC/C,6DAAsD;AACtD,oDAAyD;AAEzD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,qBAAc,CAAC,CAAC;AAE3B,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACnE,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,IAAI,GAAG,MAAM,OAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,cAAK,CAAC,CAAC,KAAK,CAAC,IAAA,gBAAE,EAAC,cAAK,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAChF,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,gBAAgB,EAAE,GAAG,CAAC,CAAC;IACzD,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,qBAAY,CAAC;SAClB,KAAK,CAAC,IAAA,gBAAE,EAAC,qBAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;SACtC,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,eAAe,GAAG,QAAQ,CAAC,CAAC,CAAC,EAAE,eAAe,CAAC;IACnD,IAAI,eAAe,EAAE,CAAC;QACpB,MAAM,GAAG,GAAG,MAAM,OAAE;aACjB,MAAM,EAAE;aACR,IAAI,CAAC,mBAAU,CAAC;aAChB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,mBAAU,CAAC,EAAE,EAAE,eAAe,CAAC,EAAE,IAAA,gBAAE,EAAC,mBAAU,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;aAC7E,KAAK,CAAC,CAAC,CAAC,CAAC;QACZ,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrB,eAAe,GAAG,IAAI,CAAC;QACzB,CAAC;IACH,CAAC;IAED,GAAG,CAAC,IAAI,CAAC;QACP,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE;QACd,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ;QAC1B,QAAQ,EAAE;YACR,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,IAAI,KAAK;YACxC,aAAa,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,aAAa,IAAI,IAAI;YACjD,YAAY,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,YAAY,IAAI,OAAO;YAClD,eAAe;YACf,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,IAAI,SAAS;YACxC,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,SAAS,IAAI,KAAK;SAC3C;QACD,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS;KAC7B,CAAC,CAAC;AACL,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,KAAK,CAAC,cAAc,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IAC9E,MAAM,IAAI,GAAG,+BAAkB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,uCAAuC;IACvC,IAAI,IAAI,CAAC,eAAe,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,MAAM,OAAE;aACjB,MAAM,EAAE;aACR,IAAI,CAAC,mBAAU,CAAC;aAChB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,mBAAU,CAAC,EAAE,EAAE,IAAI,CAAC,eAAe,CAAC,EAAE,IAAA,gBAAE,EAAC,mBAAU,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;aAClF,KAAK,CAAC,CAAC,CAAC,CAAC;QACZ,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrB,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,4BAA4B,EAAE,GAAG,CAAC,CAAC;QACrE,CAAC;IACH,CAAC;IAED,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,qBAAY,CAAC;SAClB,KAAK,CAAC,IAAA,gBAAE,EAAC,qBAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;SACtC,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,OAAE,CAAC,MAAM,CAAC,qBAAY,CAAC,CAAC,MAAM,CAAC;YACnC,MAAM;YACN,GAAG,IAAI;YACP,SAAS,EAAE,GAAG;SACf,CAAC,CAAC;IACL,CAAC;SAAM,CAAC;QACN,MAAM,OAAE;aACL,MAAM,CAAC,qBAAY,CAAC;aACpB,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC;aAChC,KAAK,CAAC,IAAA,gBAAE,EAAC,qBAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC5C,CAAC;IAED,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;AAC1C,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACvE,MAAM,EAAE,CAAC,EAAE,GAAG,GAAG,CAAC,KAAK,CAAC;IACxB,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,IAAI,CAAC,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAChD,OAAO,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtB,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,OAAE;SACrB,MAAM,CAAC,EAAE,EAAE,EAAE,cAAK,CAAC,EAAE,EAAE,QAAQ,EAAE,cAAK,CAAC,QAAQ,EAAE,CAAC;SAClD,IAAI,CAAC,cAAK,CAAC;SACX,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,mBAAK,EAAC,cAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,IAAA,gBAAE,EAAC,cAAK,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;SACjE,KAAK,CAAC,EAAE,CAAC,CAAC;IAEb,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACpB,CAAC,CAAC,CAAC,CAAC;AAEJ,kBAAe,MAAM,CAAC"}
|
||||||
Vendored
+83
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":""}
|
||||||
Vendored
+5
@@ -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
@@ -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"}
|
||||||
Vendored
+9
@@ -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
@@ -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"}
|
||||||
Vendored
+9
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+70
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+649
@@ -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
@@ -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"}
|
||||||
Vendored
+101
@@ -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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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:
|
||||||
@@ -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!,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+3029
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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`),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user