Compare commits
27
Commits
staging-qa
...
dev/safe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11288d0e0
|
||
|
|
8bf4c04f0b
|
||
|
|
9255533215
|
||
|
|
0e034e0da3
|
||
|
|
96dd20e06b
|
||
|
|
b289382747
|
||
|
|
c66e65d718
|
||
|
|
6b35bc7860
|
||
|
|
9e162bd595
|
||
|
|
f67d8d3797
|
||
|
|
79203b1654
|
||
|
|
7413a296fb
|
||
|
|
1d2c6f1ad8
|
||
|
|
53ae20a2cc
|
||
|
|
51810cad21
|
||
|
|
ea0bca8b69
|
||
|
|
e4538ed95a
|
||
|
|
c5699714dc
|
||
|
|
be9b00dec6
|
||
|
|
1f2db13168
|
||
|
|
872b90e171
|
||
|
|
eec1cd22cb
|
||
|
|
05f27e34d7
|
||
|
|
5b784ca87c
|
||
|
|
832c8e078b
|
||
|
|
673c91ec9d | ||
|
|
1111688950 |
@@ -1,23 +0,0 @@
|
||||
name: Deploy to Cloudflare
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [qas]
|
||||
paths:
|
||||
- 'apps/api/**'
|
||||
- 'docker-compose.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Runner lives on the same home machine (ux) that Cloudflare Tunnel
|
||||
# already points at — no WireGuard/SSH hop needed like the droplet
|
||||
# workflow, the runner IS the target.
|
||||
runs-on: [self-hosted, ux]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rebuild and restart backend
|
||||
run: docker compose up -d --build backend
|
||||
@@ -1,48 +1,100 @@
|
||||
name: Deploy to droplet
|
||||
name: Deploy to QAS droplet
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/api/**'
|
||||
- 'docker-compose.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: production-deployment
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# nodejs-api only accepts SSH over its WireGuard tunnel (public :22 was
|
||||
# removed). This peer is scoped to AllowedIPs 10.100.1.3/32 on the
|
||||
# droplet side, so it can only ever reach 10.100.1.1 — nothing else.
|
||||
- name: Bring up WireGuard tunnel
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install WireGuard
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y wireguard-tools
|
||||
|
||||
# The CI peer must be configured on the droplet's wg0 interface:
|
||||
# PublicKey = <public key matching CI_WG_PRIVATE_KEY>
|
||||
# AllowedIPs = 10.100.2.11/32
|
||||
- name: Bring up production WireGuard tunnel
|
||||
env:
|
||||
CI_WG_PRIVATE_KEY: ${{ secrets.CI_WG_PRIVATE_KEY }}
|
||||
DROPLET_WG_PUBLIC_KEY: ${{ secrets.DROPLET_WG_PUBLIC_KEY }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
sudo mkdir -p /etc/wireguard
|
||||
cat <<EOF | sudo tee /etc/wireguard/wg0.conf > /dev/null
|
||||
cat <<EOF | sudo tee /etc/wireguard/new-starr-ci.conf > /dev/null
|
||||
[Interface]
|
||||
PrivateKey = ${{ secrets.CI_WG_PRIVATE_KEY }}
|
||||
Address = 10.100.1.3/32
|
||||
PrivateKey = ${CI_WG_PRIVATE_KEY}
|
||||
Address = 10.100.2.11/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${{ secrets.NODEJS_API_WG_PUBLIC_KEY }}
|
||||
Endpoint = 68.183.239.171:51820
|
||||
AllowedIPs = 10.100.1.1/32
|
||||
PublicKey = ${DROPLET_WG_PUBLIC_KEY}
|
||||
Endpoint = 161.35.103.214:51820
|
||||
AllowedIPs = 10.100.2.1/32
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
sudo wg-quick up wg0
|
||||
sudo wg-quick up /etc/wireguard/new-starr-ci.conf
|
||||
|
||||
# The key below is restricted server-side to only run
|
||||
# /opt/new_starr/deploy.sh (see authorized_keys forced-command on the
|
||||
# droplet) — it can't run arbitrary commands even if this secret leaks.
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: 10.100.1.1
|
||||
username: deploy
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: deploy
|
||||
- name: Configure SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
install -d -m 700 "$HOME/.ssh"
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > "$HOME/.ssh/new-starr-deploy"
|
||||
chmod 600 "$HOME/.ssh/new-starr-deploy"
|
||||
ssh-keyscan -H 10.100.2.1 >> "$HOME/.ssh/known_hosts"
|
||||
|
||||
- name: Build frontend bundle
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
scp -i "$HOME/.ssh/new-starr-deploy" \
|
||||
-o IdentitiesOnly=yes \
|
||||
starr-deploy@10.100.2.1:/home/starr-deploy/new_starr/apps/web/.env \
|
||||
apps/web/.env
|
||||
corepack enable
|
||||
corepack prepare pnpm@11.3.0 --activate
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm --filter web build
|
||||
|
||||
- name: Upload source
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
tar \
|
||||
--exclude=.git \
|
||||
--exclude=node_modules \
|
||||
--exclude='apps/api/.env' \
|
||||
--exclude='apps/web/.env' \
|
||||
-czf - . |
|
||||
ssh -i "$HOME/.ssh/new-starr-deploy" \
|
||||
-o IdentitiesOnly=yes \
|
||||
starr-deploy@10.100.2.1 \
|
||||
'rm -rf "$HOME/new_starr-release" && mkdir -p "$HOME/new_starr-release" && tar -xzf - -C "$HOME/new_starr-release"'
|
||||
|
||||
- name: Build and restart production stack
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
ssh -i "$HOME/.ssh/new-starr-deploy" \
|
||||
-o IdentitiesOnly=yes \
|
||||
starr-deploy@10.100.2.1 <<'REMOTE'
|
||||
set -Eeuo pipefail
|
||||
cd "$HOME/new_starr-release"
|
||||
test -f "$HOME/new_starr/apps/api/.env"
|
||||
test -f "$HOME/new_starr/apps/web/.env"
|
||||
test -f "$HOME/new_starr/.env"
|
||||
cp "$HOME/new_starr/apps/api/.env" apps/api/.env
|
||||
cp "$HOME/new_starr/apps/web/.env" apps/web/.env
|
||||
cp "$HOME/new_starr/.env" .env
|
||||
docker compose -p new_starr up -d --build --remove-orphans
|
||||
docker compose -p new_starr ps
|
||||
REMOTE
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ── Dependencies ──────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
|
||||
# ── Environment / Secrets ─────────────────────────────────────────────────────
|
||||
.env
|
||||
@@ -27,6 +28,9 @@ yarn-debug.log*
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||
test-results/
|
||||
|
||||
# ── Uploads / Temp ────────────────────────────────────────────────────────────
|
||||
uploads/
|
||||
tmp/
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Reverse proxy template for a REAL deployment (real domain + automatic HTTPS).
|
||||
# Not needed for local testing against localhost:4650 — this is the step you
|
||||
# take once you're actually putting this in front of a public domain.
|
||||
#
|
||||
# Our recommendations where to deploy this streamable online course and training platform
|
||||
# is only either Cloudflare (with Tunnels For free) or other cloud VPS platforms (DigitalOcean,Google et.al)
|
||||
#
|
||||
# Usage:
|
||||
# 1. Point 3 DNS records at this machine: your app domain, your api
|
||||
# subdomain, and your cdn subdomain (matching FRONTEND_URL / APP_URL /
|
||||
# S3_PUBLIC_URL in apps/api/.env and VITE_APP_URL / VITE_API_URL in
|
||||
# apps/web/.env). Either edit the three domains below directly, or set
|
||||
# the FRONTEND_DOMAIN / API_DOMAIN / CDN_DOMAIN env vars before running
|
||||
# Caddy and leave the placeholders as-is.
|
||||
# 2. Install Caddy (https://caddyserver.com/docs/install) directly on this
|
||||
# host — it is NOT part of docker-compose.yml, so it binds host ports
|
||||
# 80/443 directly and reverse-proxies to the containers' published
|
||||
# ports below.
|
||||
# 3. Copy this file to /etc/caddy/Caddyfile (or wherever your install
|
||||
# expects it).
|
||||
# 4. sudo systemctl reload caddy (or: caddy run --config Caddyfile)
|
||||
#
|
||||
# Caddy issues and renews Let's Encrypt certificates automatically the first
|
||||
# time each domain is requested — no manual cert steps needed.
|
||||
|
||||
{
|
||||
servers {
|
||||
trusted_proxies static private_ranges
|
||||
client_ip_headers X-Forwarded-For X-Real-IP
|
||||
}
|
||||
}
|
||||
|
||||
{$FRONTEND_DOMAIN:yourdomain.com} {
|
||||
encode gzip zstd
|
||||
|
||||
reverse_proxy localhost:4650 {
|
||||
header_up X-Real-IP {http.request.header.X-Real-IP}
|
||||
}
|
||||
|
||||
handle_errors {
|
||||
respond "Frontend temporarily unavailable" 502
|
||||
}
|
||||
}
|
||||
|
||||
{$API_DOMAIN:api.yourdomain.com} {
|
||||
encode gzip zstd
|
||||
|
||||
reverse_proxy localhost:3024 {
|
||||
header_up X-Real-IP {http.request.header.X-Real-IP}
|
||||
}
|
||||
|
||||
handle_errors {
|
||||
respond "API temporarily unavailable" 502
|
||||
}
|
||||
}
|
||||
|
||||
{$CDN_DOMAIN:cdn.yourdomain.com} {
|
||||
# Browser-direct presigned PUT uploads need a storage CORS preflight.
|
||||
# Garage's default bucket policy only advertises GET, so handle the
|
||||
# browser preflight at the public edge and expose multipart ETags.
|
||||
@storage_preflight method OPTIONS
|
||||
header @storage_preflight {
|
||||
Access-Control-Allow-Origin "{http.request.header.Origin}"
|
||||
Access-Control-Allow-Methods "GET, HEAD, PUT, POST, DELETE, OPTIONS"
|
||||
Access-Control-Allow-Headers "{http.request.header.Access-Control-Request-Headers}"
|
||||
Access-Control-Max-Age "86400"
|
||||
}
|
||||
respond @storage_preflight 204
|
||||
|
||||
header {
|
||||
Access-Control-Allow-Origin "*"
|
||||
Access-Control-Expose-Headers "ETag, Content-Length, Content-Range, Accept-Ranges, Content-Disposition"
|
||||
}
|
||||
|
||||
reverse_proxy localhost:3900 {
|
||||
header_up X-Real-IP {http.request.header.X-Real-IP}
|
||||
header_down -Access-Control-Allow-Origin
|
||||
header_down -Access-Control-Allow-Methods
|
||||
header_down -Access-Control-Allow-Headers
|
||||
header_down -Access-Control-Expose-Headers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# STAR Phase 1 — Tier 1 Auth System
|
||||
**Version:** 1.0.0
|
||||
**Date:** October 6, 2025
|
||||
**Stack:** Node.js · Express · Sequelize (PostgreSQL) · JWT · Google OAuth 2.0 · Nodemailer
|
||||
**Deployment** DigitalOcean Droplet (2 Droplets for API + S3 and Postgres)
|
||||
|
||||
## Contributing
|
||||
We expect that you are in `dev/safe` branch so we recommend to have your own forked repository here's breakdown:
|
||||
|
||||
```
|
||||
BRANCH_NAME | DESCRIPTION
|
||||
-------------------------
|
||||
main - A protected branch maintain by creators
|
||||
staging-qa - Everytime na may Pull Requests (PR) from your forked repository dito mismo sa branch na ito kami mag-check per feature, per modification and also bug.
|
||||
staging-uat - Supposedly has working Github Action so I will tell about it.
|
||||
dev/safe - In your forked repository always use this branch
|
||||
```
|
||||
|
||||
## Self-hosting
|
||||
See [SELF_HOSTING.md](./SELF_HOSTING.md) for the full Docker Compose setup guide.
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
# Self-Hosting `new_starr` (Docker Compose) — Step-by-Step
|
||||
|
||||
### ***Video tutorial soon***
|
||||
|
||||
This stack bundles everything: PostgreSQL, Valkey (Redis), Garage (S3), the API, and the
|
||||
frontend. Battle-tested end-to-end on a completely fresh Debian machine on 2026-08-29
|
||||
every gotcha below actually happened once and is now fixed or documented.
|
||||
|
||||
---
|
||||
|
||||
## 0. What you need
|
||||
|
||||
- Docker Engine + Compose plugin
|
||||
- Node.js 22 + pnpm (only needed once, to build the frontend bundle)
|
||||
- Git access to this repo
|
||||
|
||||
---
|
||||
|
||||
## 1. Install Docker
|
||||
|
||||
This block auto-detects `debian` vs `ubuntu` from `/etc/os-release` **and** probes
|
||||
Docker's repo for your codename before adding it — if your codename is too new and 404s
|
||||
(interim/dev releases lag Docker's repo by months), it automatically falls back to the
|
||||
latest codename Docker actually publishes for your distro. No manual edits needed:
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ca-certificates curl gnupg
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
|
||||
. /etc/os-release
|
||||
DOCKER_DISTRO="$ID" # debian or ubuntu
|
||||
DOCKER_CODENAME="$VERSION_CODENAME"
|
||||
|
||||
# Fallback list, newest first, per distro — used only if the detected codename 404s
|
||||
case "$DOCKER_DISTRO" in
|
||||
ubuntu) FALLBACKS="noble jammy focal" ;;
|
||||
debian) FALLBACKS="bookworm bullseye" ;;
|
||||
*) echo "Unsupported distro '$DOCKER_DISTRO' — Docker only publishes debian/ubuntu repos." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if ! curl -fsSL -o /dev/null "https://download.docker.com/linux/${DOCKER_DISTRO}/dists/${DOCKER_CODENAME}/Release"; then
|
||||
echo "Docker has no repo for ${DOCKER_DISTRO} ${DOCKER_CODENAME} yet, falling back..." >&2
|
||||
for fb in $FALLBACKS; do
|
||||
if curl -fsSL -o /dev/null "https://download.docker.com/linux/${DOCKER_DISTRO}/dists/${fb}/Release"; then
|
||||
DOCKER_CODENAME="$fb"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "Using ${DOCKER_DISTRO} ${DOCKER_CODENAME}" >&2
|
||||
fi
|
||||
|
||||
curl -fsSL "https://download.docker.com/linux/${DOCKER_DISTRO}/gpg" | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${DOCKER_DISTRO} \
|
||||
${DOCKER_CODENAME} stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
(The `FALLBACKS` lists above are current as of 2026-08 — bump them if Docker ships a
|
||||
newer stable codename by the time you read this.)
|
||||
|
||||
Let your user run Docker without `sudo`:
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
docker compose version # sanity check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Clone & install
|
||||
|
||||
```bash
|
||||
git clone git@github.com:rgrgogu/new_starr.git
|
||||
cd new_starr
|
||||
corepack enable && corepack prepare pnpm@11.3.0 --activate
|
||||
pnpm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configure environment — **three** files, not two
|
||||
|
||||
### `apps/api/.env`
|
||||
```bash
|
||||
cp apps/api/.env-production apps/api/.env
|
||||
```
|
||||
Fill in every `CHANGE_ME`. For a **local bundled stack** (as opposed to CockroachDB
|
||||
Cloud / managed S3), these specific values matter:
|
||||
```env
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_SSL=false # bundled Postgres has no TLS
|
||||
CACHE_DRIVER=redis
|
||||
REDIS_URL=redis://valkey:6379
|
||||
S3_ENDPOINT=http://garage:3900
|
||||
GARAGE_RPC_SECRET=<openssl rand -hex 32>
|
||||
```
|
||||
Generate every secret independently:
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
### `apps/web/.env`
|
||||
```bash
|
||||
cd apps/web && cp .env.example .env
|
||||
```
|
||||
Fill in `VITE_API_URL`, `VITE_APP_URL`, etc., then `cd ../..`.
|
||||
|
||||
### Root `new_starr/.env` — easy to miss, causes silent failures
|
||||
`docker-compose.yml`'s bundled `postgres` service reads `${DB_NAME}`, `${DB_USER}`,
|
||||
`${DB_PASSWORD}` from a **root-level** `.env` — NOT from `apps/api/.env`. Create it
|
||||
separately, with **exactly the same values** as the DB block you just put in
|
||||
`apps/api/.env`:
|
||||
```env
|
||||
DB_NAME=starr
|
||||
DB_USER=starr
|
||||
DB_PASSWORD=<same password as apps/api/.env>
|
||||
```
|
||||
⚠️ Postgres only reads these once, at first-ever container start, and bakes them into
|
||||
its data volume. If the two files ever drift apart afterward, you'll get
|
||||
`password authentication failed` even though everything "looks" configured — fix by
|
||||
making `apps/api/.env` match what's actually in the volume (the root `.env` value at
|
||||
the time Postgres first started), not the other way around.
|
||||
|
||||
---
|
||||
|
||||
## 4. Build the frontend bundle *before* building images
|
||||
|
||||
`apps/web/Dockerfile` only copies a pre-built `dist/` — it does not run the build itself:
|
||||
```bash
|
||||
pnpm --filter web build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Bring the stack up
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose ps # all 5 services should show Up/healthy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. One-time Garage (S3) cluster init
|
||||
|
||||
Garage's image ships only a static binary — no shell — so this can't be scripted as a
|
||||
container entrypoint; it's a manual one-time step:
|
||||
```bash
|
||||
docker compose exec garage /garage node id
|
||||
```
|
||||
Copy the ID (the part **before** the `@host:port`), then:
|
||||
```bash
|
||||
docker compose exec garage /garage layout assign <NODE_ID> -z dc1 -c 100G
|
||||
docker compose exec garage /garage layout apply --version 1
|
||||
docker compose exec garage /garage bucket create <S3_BUCKET>
|
||||
docker compose exec garage /garage key import <S3_ACCESS_KEY> <S3_SECRET_KEY> -n starr-app --yes
|
||||
docker compose exec garage /garage bucket allow <S3_BUCKET> --read --write --owner --key <S3_ACCESS_KEY>
|
||||
```
|
||||
(`S3_BUCKET`/`S3_ACCESS_KEY`/`S3_SECRET_KEY` are whatever you set in `apps/api/.env`.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Run database migrations
|
||||
|
||||
```bash
|
||||
docker compose exec backend sequelize-cli db:migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Verify
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
- Frontend: **http://localhost:4650**
|
||||
- Backend: http://localhost:3024/api
|
||||
|
||||
If both respond, you're done with local testing.
|
||||
|
||||
---
|
||||
|
||||
## 9. (Optional) Put a real domain + HTTPS in front
|
||||
|
||||
For actually deploying this somewhere reachable, use the `Caddyfile` at the repo root
|
||||
as a template. It's a standalone Caddy install on the host (not part of
|
||||
`docker-compose.yml`) that reverse-proxies 3 domains to the 3 ports already published
|
||||
above (`:4650`, `:3024`, `:3900`) and handles Let's Encrypt certificates automatically.
|
||||
See the comments at the top of `Caddyfile` for exact steps. Not needed for local
|
||||
`localhost:4650` testing — skip this until you're pointing a real domain at the
|
||||
machine.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting — real errors hit while writing this guide
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `apt` 404: `.../linux/<distro> <codename> Release` not found | Step 1's install block already auto-detects and falls back — this only happens if you ran the old manual commands, or your codename is newer than every entry in `FALLBACKS` | Re-run Step 1's block as-is, or add your distro's actual latest stable codename to `FALLBACKS` |
|
||||
| `permission denied ... docker.sock` | User not in the `docker` group | `sudo usermod -aG docker $USER && newgrp docker` |
|
||||
| `WARN: "DB_NAME" variable is not set` (and similar) | Root `new_starr/.env` missing | Create it (see step 3) |
|
||||
| `postgres` container stuck `Restarting` | `POSTGRES_PASSWORD` blank (same root `.env` issue) | Same fix as above |
|
||||
| `exec: "/bin/sh": stat /bin/sh: no such file or directory` | Some images (like Garage's) ship no shell at all — never wrap their entrypoint in `/bin/sh` | Use the image's default command; do cluster setup via `docker exec <container> <binary>` directly (no shell needed) |
|
||||
| Garage crash-loops: `rpc_secret value is missing` | `GARAGE_RPC_SECRET` not set in `apps/api/.env` | Set it, then `docker compose up -d --force-recreate garage` |
|
||||
| `password authentication failed for user ...` | `apps/api/.env` and root `.env` DB passwords don't match exactly | Make them match; Postgres only honors whatever was set at first boot |
|
||||
| Fixed a file but the same error keeps happening | Images bake in code at **build** time — there's no live volume mount | `docker compose up -d --build <service>` after any source change |
|
||||
| `ERROR: Unknown constraint error` during migrate | Sequelize hides the real Postgres error behind this generic message | Run the raw SQL directly via `docker compose exec postgres psql -U <user> -d <db>` to see the actual cause |
|
||||
| Migration uses `STRING` as a raw SQL type | CockroachDB alias, not valid in real Postgres | Already fixed in this repo (swapped to `TEXT`, which works on both) |
|
||||
| A service vanishes from `docker compose ps` | A previous `up` was scoped to one service (e.g. `up -d --build backend`), which doesn't touch others | Run a plain `docker compose up -d` (no service name) to restore everything |
|
||||
| Browser: "Unable to connect" | Either the container isn't actually running, or you're browsing from a different machine than the one running Docker | Check `docker compose ps` first; `localhost` only resolves to the machine Docker is on |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Frontend is served on **`:4650`**, not `:80` — change `frontend.ports` in
|
||||
`docker-compose.yml` if you want a different port.
|
||||
- Cluster init for Garage is intentionally manual (step 6) — the old automated
|
||||
`garage-init.sh` approach was removed because it depended on a shell that doesn't
|
||||
exist in the Garage image.
|
||||
+6
-4
@@ -10,17 +10,19 @@ RUN npm install -g pm2 sequelize-cli
|
||||
|
||||
# Typst — compiles certificate.typ into the downloadable PDF certificate.
|
||||
# Not available as an apk package, so lift the binary out of pandoc/typst.
|
||||
COPY --from=typst /usr/bin/typst /usr/local/bin/typst
|
||||
COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests first (cache-friendly), then a filtered install —
|
||||
# pnpm needs every workspace package's manifest present to resolve the
|
||||
# graph even though only apps/api's deps get installed here.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# graph even though only apps/api's deps get installed here. pnpm-lock.yaml
|
||||
# is gitignored (not committed), so this resolves fresh on every build
|
||||
# rather than installing from a frozen lockfile.
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
COPY apps/api/package.json ./apps/api/package.json
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
RUN pnpm install --frozen-lockfile --filter api...
|
||||
RUN pnpm install --filter api...
|
||||
|
||||
COPY apps/api ./apps/api
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# STAR Phase 1 — Tier 1 Auth System
|
||||
## Technical Documentation
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Date:** October 6, 2025
|
||||
**Stack:** Node.js · Express · Sequelize (PostgreSQL) · JWT · Google OAuth 2.0 · Nodemailer
|
||||
**Deployment** DigitalOcean Droplet (3 Droplets for API + S3 and Postgres)
|
||||
|
||||
## TODO
|
||||
Branchings and Tags (Backups)
|
||||
```
|
||||
main (protected) → prod
|
||||
qas (protected) → staging
|
||||
dev (protected*) → integration/playground
|
||||
feature/xyz → short-lived, deleted after merge
|
||||
hotfix/xyz → for urgent prod patches
|
||||
+ tags (v1.0.9...) → released snapshots
|
||||
```
|
||||
@@ -10,6 +10,9 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op, ForeignKeyConstraintError } = require('sequelize');
|
||||
|
||||
const sequelize = require('../../config/db.config');
|
||||
const { withTransactionRetry } = require('../../utils/withTransactionRetry.util');
|
||||
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
@@ -348,9 +351,12 @@ exports.permanentlyDeletePlan = async (req, res) => {
|
||||
// by default) — a plan can't be force-destroyed while payment rows still
|
||||
// reference it, so those rows are force-destroyed first. This permanently
|
||||
// erases that plan's payment/billing history; there is no undo.
|
||||
const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: plan.plan_id }, force: true });
|
||||
const deleted_payment_count = await withTransactionRetry(sequelize, async (t) => {
|
||||
const count = await mdl_Payments.destroy({ where: { plan_id: plan.plan_id }, force: true, transaction: t });
|
||||
await plan.destroy({ force: true, transaction: t });
|
||||
return count;
|
||||
});
|
||||
|
||||
await plan.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, deleted_payment_count, revoked_user_count } });
|
||||
return R.success(res, 'Plan permanently deleted.', { deleted_payment_count, revoked_user_count });
|
||||
} catch (err) {
|
||||
@@ -387,9 +393,11 @@ exports.bulkPermanentlyDeletePlans = async (req, res) => {
|
||||
// by default) — plans can't be force-destroyed while payment rows still
|
||||
// reference them, so those rows are force-destroyed first. This permanently
|
||||
// erases these plans' payment/billing history; there is no undo.
|
||||
const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: archivedIds }, force: true });
|
||||
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true });
|
||||
const deleted_payment_count = await withTransactionRetry(sequelize, async (t) => {
|
||||
const count = await mdl_Payments.destroy({ where: { plan_id: archivedIds }, force: true, transaction: t });
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true, transaction: t });
|
||||
return count;
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length, deleted_payment_count, revoked_user_count } });
|
||||
return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, {
|
||||
|
||||
@@ -7,21 +7,22 @@
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* Change History:
|
||||
* DATE AUTHOR LOG DESCRIPTION
|
||||
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
|
||||
* May 23, 2026 rgrgogu 002 Added group_code to createGroup / updateGroup; added generateGroupCode helper
|
||||
* DATE AUTHOR LOG DESCRIPTION
|
||||
* Oct 06,2025 rgrgogu 001 Initial creation - STAR Phase 1
|
||||
* May 23,2026 rgrgogu 002 Added group_code to createGroup / updateGroup; added generateGroupCode helper
|
||||
* Sept 24,2026 Kenneth Obsequio 003 Limit group_code format for long text.
|
||||
***********************************************************************************************************************************************************************/
|
||||
const sequelize = require('../../config/db.config');
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const { getFieldValues } = require('../../utils/fieldValues.util');
|
||||
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
|
||||
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../utils/defaultGroup.util');
|
||||
@@ -32,26 +33,60 @@ const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../uti
|
||||
* e.g. "SALES-A3F1", "ONBOARD-Q1-9C2D"
|
||||
* Retries up to 5 times in the unlikely event of a collision.
|
||||
*/
|
||||
|
||||
const LIMITWORDS = new Set(['OF', 'THE', 'AND', 'FOR', 'TO', 'IN', 'A', 'AN']);
|
||||
|
||||
const buildSlugGroupCode = (name, maxLen = 4) => {
|
||||
const words = name
|
||||
.toUpperCase()
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(w => w.replace(/[^A-Z0-9]/g, ''))
|
||||
.filter(w => w.length > 0 && !LIMITWORDS.has(w));
|
||||
|
||||
if (words.length === 0) return 'GROUP'.slice(0, maxLen); // ensure fallback also respects cap
|
||||
|
||||
// Single word -> just truncate it (e.g. "Sales" -> "SALE")
|
||||
if (words.length === 1) {
|
||||
return words[0].slice(0, maxLen);
|
||||
}
|
||||
|
||||
// Multiple words -> take first letter of each
|
||||
const acronym = words.map(w => w[0]).join('');
|
||||
|
||||
// Guard against 1-letter acronyms (e.g. two 1-word-after-filtering edge cases)
|
||||
return acronym.length >= 2 ? acronym.slice(0, maxLen) : words[0].slice(0, maxLen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique group_code in the format: <SLUG>-<4-char hex>
|
||||
* e.g. "GA-F2CA", "SALES-9C2D"
|
||||
* Retries up to 5 times in the unlikely event of a collision.
|
||||
*/
|
||||
const generateGroupCode = async (name) => {
|
||||
const slug = name.toUpperCase().trim().replace(/\s+/g, '-').replace(/[^A-Z0-9\-]/g, '').slice(0, 20);
|
||||
const slug = buildSlugGroupCode(name);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const suffix = Math.random().toString(16).slice(2, 6).toUpperCase();
|
||||
const code = `${slug}-${suffix}`;
|
||||
const code = `${slug}-${suffix}`;
|
||||
const exists = await mdl_UserGroups.findOne({ where: { group_code: code }, paranoid: false });
|
||||
if (!exists) return code;
|
||||
}
|
||||
throw new Error('Could not generate a unique group code after 5 attempts.');
|
||||
};
|
||||
|
||||
// ─── Exports for unit testing ──────────────────────────────────────────────
|
||||
exports.__test__ = { buildSlugGroupCode, generateGroupCode };
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
exports.getGroups = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_UserGroups, req, {
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
computedAttributes: groupComputed,
|
||||
context: 'list',
|
||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||
context: 'list',
|
||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||
});
|
||||
|
||||
return R.success(res, 'Groups retrieved.', result);
|
||||
@@ -69,16 +104,16 @@ exports.getGroup = async (req, res) => {
|
||||
|
||||
const members = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
context: 'list',
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
context: 'list',
|
||||
findOptions: {
|
||||
include: [{
|
||||
model: mdl_UserGroupMembers,
|
||||
where: { group_id: req.params.gid },
|
||||
model: mdl_UserGroupMembers,
|
||||
where: { group_id: req.params.gid },
|
||||
attributes: [],
|
||||
required: true,
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
});
|
||||
@@ -109,7 +144,7 @@ exports.createGroup = async (req, res) => {
|
||||
name,
|
||||
description,
|
||||
group_code: code,
|
||||
createdBy: req.user.user_id,
|
||||
createdBy: req.user.user_id,
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'create_group', { entityType: 'group', entityId: group.group_id, details: { name: group.name, group_code: group.group_code } });
|
||||
@@ -128,11 +163,11 @@ exports.updateGroup = async (req, res) => {
|
||||
|
||||
const { name, description, group_code } = req.body;
|
||||
|
||||
if (name !== undefined) group.name = name;
|
||||
if (name !== undefined) group.name = name;
|
||||
if (description !== undefined) group.description = description;
|
||||
|
||||
if (group_code !== undefined) {
|
||||
const code = group_code.toUpperCase().trim();
|
||||
const code = group_code.toUpperCase().trim();
|
||||
const duplicate = await mdl_UserGroups.findOne({
|
||||
where: { group_code: code, group_id: { [Op.ne]: group.group_id } },
|
||||
paranoid: false,
|
||||
@@ -156,7 +191,7 @@ exports.updateGroup = async (req, res) => {
|
||||
exports.deactivateGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400);
|
||||
|
||||
await group.update({ is_active: false, updatedBy: req.user.user_id, deletedBy: req.user.user_id });
|
||||
@@ -174,7 +209,7 @@ exports.deactivateGroup = async (req, res) => {
|
||||
exports.restoreGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false });
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (group.is_active) return R.error(res, 'Group is already active.', 400);
|
||||
|
||||
await group.restore();
|
||||
@@ -195,7 +230,7 @@ exports.bulkDeactivateGroups = async (req, res) => {
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No group IDs provided.', 400);
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids } });
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids } });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
|
||||
const activeGroups = groups.filter((g) => g.is_active && !g.deletedAt);
|
||||
@@ -213,7 +248,7 @@ exports.bulkDeactivateGroups = async (req, res) => {
|
||||
logActivity(req.user.user_id, 'bulk_deactivate_groups', { entityType: 'group', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} group(s) deactivated successfully.`, {
|
||||
deactivated_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err);
|
||||
@@ -228,8 +263,8 @@ exports.bulkRestoreGroups = async (req, res) => {
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No group IDs provided.', 400);
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
|
||||
const deletedGroups = groups.filter((g) => g.deletedAt);
|
||||
if (!deletedGroups.length)
|
||||
@@ -246,7 +281,7 @@ exports.bulkRestoreGroups = async (req, res) => {
|
||||
logActivity(req.user.user_id, 'bulk_restore_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } });
|
||||
return R.success(res, `${deletedIds.length} group(s) restored successfully.`, {
|
||||
restored_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK RESTORE GROUPS]', err);
|
||||
@@ -258,7 +293,7 @@ exports.bulkRestoreGroups = async (req, res) => {
|
||||
exports.permanentlyDeleteGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false });
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group.deletedAt) return R.error(res, 'Group must be deactivated before it can be permanently deleted.', 400);
|
||||
|
||||
await group.destroy({ force: true });
|
||||
@@ -278,8 +313,8 @@ exports.bulkPermanentlyDeleteGroups = async (req, res) => {
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No group IDs provided.', 400);
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
|
||||
const deletedGroups = groups.filter((g) => g.deletedAt);
|
||||
if (!deletedGroups.length)
|
||||
@@ -304,14 +339,14 @@ exports.bulkPermanentlyDeleteGroups = async (req, res) => {
|
||||
exports.getArchivedGroups = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_UserGroups, req, {
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
computedAttributes: groupComputed,
|
||||
context: 'archived',
|
||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||
context: 'archived',
|
||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: { deletedAt: { [Op.ne]: null }, is_active: false },
|
||||
where: { deletedAt: { [Op.ne]: null }, is_active: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -325,7 +360,7 @@ exports.getArchivedGroups = async (req, res) => {
|
||||
// ─── FIELD VALUES ─────────────────────────────────────────────────────────────
|
||||
exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, 'GROUP', {
|
||||
blockedFields: ['deletedAt'],
|
||||
paranoid: false,
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
// ─── MEMBERSHIP ───────────────────────────────────────────────────────────────
|
||||
@@ -334,12 +369,12 @@ exports.getUsersNotInGroup = async (req, res) => {
|
||||
const { gid: group_id } = req.params;
|
||||
|
||||
// Exclude users already in THIS group
|
||||
const members = await mdl_UserGroupMembers.findAll({ where: { group_id }, attributes: ['user_id'] });
|
||||
const members = await mdl_UserGroupMembers.findAll({ where: { group_id }, attributes: ['user_id'] });
|
||||
const memberIds = members.map((m) => m.user_id);
|
||||
|
||||
const users = await mdl_Users.findAll({
|
||||
where: {
|
||||
user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] },
|
||||
user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] },
|
||||
acc_type: 'user', // exclude staff/admin — only regular users can be added to a group
|
||||
},
|
||||
attributes: [
|
||||
@@ -374,9 +409,9 @@ exports.getUsersInGroup = async (req, res) => {
|
||||
|
||||
const group = await mdl_UserGroups.findByPk(group_id, {
|
||||
include: [{
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
through: { attributes: [] },
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
through: { attributes: [] },
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
@@ -395,14 +430,14 @@ exports.getUsersInGroup = async (req, res) => {
|
||||
exports.addUserToGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(user_ids) || !user_ids.length)
|
||||
return R.error(res, 'No users provided.', 400);
|
||||
|
||||
const existingUsers = await mdl_Users.findAll({ where: { user_id: user_ids }, attributes: ['user_id'] });
|
||||
const existingIds = existingUsers.map((u) => u.user_id);
|
||||
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||
const existingIds = existingUsers.map((u) => u.user_id);
|
||||
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||
|
||||
if (notFound.length)
|
||||
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
|
||||
@@ -430,7 +465,7 @@ exports.addUserToGroup = async (req, res) => {
|
||||
exports.removeUserFromGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(user_ids) || !user_ids.length)
|
||||
return R.error(res, 'No users provided.', 400);
|
||||
@@ -439,7 +474,7 @@ exports.removeUserFromGroup = async (req, res) => {
|
||||
where: { user_id: user_ids, group_id }, attributes: ['user_id'],
|
||||
});
|
||||
const existingIds = existingMembers.map((m) => m.user_id);
|
||||
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||
|
||||
if (notFound.length)
|
||||
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
|
||||
|
||||
@@ -76,13 +76,17 @@ exports.captureCourseOrder = async (req, res) => {
|
||||
const { order_id } = req.body;
|
||||
if (!order_id) return R.error(res, 'order_id is required.', 400);
|
||||
|
||||
const purchase = await mdl_CoursePurchase.findOne({
|
||||
// Matched on provider_payload.order_id, not just "most recent pending" —
|
||||
// see tiers.controller.js#captureOrder for why picking by recency alone
|
||||
// can miss an older order that PayPal legitimately approved.
|
||||
const pendingPurchases = await mdl_CoursePurchase.findAll({
|
||||
where: { user_id: req.user.user_id, status: 'pending' },
|
||||
include: [{ model: mdl_Product, as: 'product' }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
const purchase = pendingPurchases.find((p) => p.provider_payload?.order_id === order_id);
|
||||
|
||||
if (!purchase || purchase.provider_payload?.order_id !== order_id)
|
||||
if (!purchase)
|
||||
return R.error(res, 'Pending purchase not found.', 404);
|
||||
|
||||
let captureData;
|
||||
@@ -144,12 +148,14 @@ exports.cancelCourseOrder = async (req, res) => {
|
||||
const { order_id } = req.body;
|
||||
if (!order_id) return R.error(res, 'order_id is required.', 400);
|
||||
|
||||
const purchase = await mdl_CoursePurchase.findOne({
|
||||
// See captureCourseOrder above for why this matches on order_id instead of recency.
|
||||
const pendingPurchases = await mdl_CoursePurchase.findAll({
|
||||
where: { user_id: req.user.user_id, status: 'pending' },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
const purchase = pendingPurchases.find((p) => p.provider_payload?.order_id === order_id);
|
||||
|
||||
if (!purchase || purchase.provider_payload?.order_id !== order_id)
|
||||
if (!purchase)
|
||||
return R.error(res, 'Pending purchase not found.', 404);
|
||||
|
||||
await purchase.update({
|
||||
|
||||
@@ -310,13 +310,19 @@ exports.captureOrder = async (req, res) => {
|
||||
const { order_id } = req.body;
|
||||
if (!order_id) return R.error(res, 'order_id is required.', 400);
|
||||
|
||||
const payment = await mdl_Payments.findOne({
|
||||
// Matched on provider_payload.order_id, not just "most recent pending" —
|
||||
// a user can have more than one pending payment at once (e.g. abandoned
|
||||
// Plan A via browser-back instead of PayPal's cancel button, then started
|
||||
// checkout on Plan B); picking by recency would miss an older order that
|
||||
// PayPal legitimately approved.
|
||||
const pendingPayments = await mdl_Payments.findAll({
|
||||
where: { status: 'pending', user_id: req.user.user_id },
|
||||
include: [{ model: mdl_TierPlans, as: 'plan' }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
const payment = pendingPayments.find((p) => p.provider_payload?.order_id === order_id);
|
||||
|
||||
if (!payment || payment.provider_payload?.order_id !== order_id)
|
||||
if (!payment)
|
||||
return R.error(res, 'Pending payment not found.', 404);
|
||||
|
||||
// Guard: plan was deactivated while user was on PayPal's approval page
|
||||
@@ -433,12 +439,14 @@ exports.cancelOrder = async (req, res) => {
|
||||
const { order_id } = req.body;
|
||||
if (!order_id) return R.error(res, 'order_id is required.', 400);
|
||||
|
||||
const payment = await mdl_Payments.findOne({
|
||||
// See captureOrder above for why this matches on order_id instead of recency.
|
||||
const pendingPayments = await mdl_Payments.findAll({
|
||||
where: { user_id: req.user.user_id, status: 'pending' },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
const payment = pendingPayments.find((p) => p.provider_payload?.order_id === order_id);
|
||||
|
||||
if (!payment || payment.provider_payload?.order_id !== order_id)
|
||||
if (!payment)
|
||||
return R.error(res, 'Pending payment not found.', 404);
|
||||
|
||||
await payment.update({
|
||||
|
||||
@@ -9,11 +9,11 @@ module.exports = {
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE completion_requirements (
|
||||
requirement_id UUID PRIMARY KEY,
|
||||
entity_type STRING NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
type STRING NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
min_percent INTEGER NULL,
|
||||
button_label STRING NULL,
|
||||
button_label TEXT NULL,
|
||||
is_required BOOLEAN NOT NULL DEFAULT true,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdBy" BIGINT NULL,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ module.exports = {
|
||||
progress_id UUID PRIMARY KEY,
|
||||
requirement_id UUID NOT NULL REFERENCES completion_requirements (requirement_id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
entity_type STRING NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
progress_percent INTEGER NULL,
|
||||
completed BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
@@ -13,7 +13,7 @@ module.exports = {
|
||||
position_id UUID PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
lesson_id BIGINT NOT NULL REFERENCES lessons (lesson_id) ON DELETE CASCADE,
|
||||
block_id STRING NOT NULL,
|
||||
block_id TEXT NOT NULL,
|
||||
percent INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE advertisements
|
||||
ALTER COLUMN badge_label TYPE STRING(100)
|
||||
ALTER COLUMN badge_label TYPE TEXT
|
||||
USING (
|
||||
CASE
|
||||
WHEN jsonb_array_length(badge_label) = 0 THEN NULL
|
||||
|
||||
@@ -40,8 +40,8 @@ exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl,
|
||||
amount: { currency_code: currency, value: String(amount) },
|
||||
}],
|
||||
application_context: {
|
||||
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`,
|
||||
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`,
|
||||
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/subscriptions/checkout`,
|
||||
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/subscriptions/checkout?cancelled=true`,
|
||||
brand_name: process.env.PAYPAL_BRAND_NAME ?? 'STARR',
|
||||
user_action: 'PAY_NOW',
|
||||
},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ctrl = require('../../controllers/client/course_purchases.controller');
|
||||
const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
||||
|
||||
router.get ('/', ctrl.getMyPurchases);
|
||||
router.post('/order', ctrl.createCourseOrder);
|
||||
router.post('/order', sensitiveOpsLimiter, ctrl.createCourseOrder);
|
||||
router.post('/capture', ctrl.captureCourseOrder);
|
||||
router.post('/cancel', ctrl.cancelCourseOrder);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ctrl = require('../../controllers/client/tiers.controller');
|
||||
const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
||||
|
||||
// My tier
|
||||
router.get ('/me', ctrl.getMyTier);
|
||||
@@ -9,11 +10,13 @@ router.get ('/me/history', ctrl.getMyTierHistory);
|
||||
// Plans
|
||||
router.get ('/plans', ctrl.getPlans);
|
||||
|
||||
// Promo code validation
|
||||
router.post('/promos/validate', ctrl.validatePromo);
|
||||
// Promo code validation — rate-limited: unthrottled retries let an attacker
|
||||
// brute-force/enumerate valid promo codes off the "Invalid promo code" reason.
|
||||
router.post('/promos/validate', sensitiveOpsLimiter, ctrl.validatePromo);
|
||||
|
||||
// Checkout
|
||||
router.post('/checkout/order', ctrl.createOrder);
|
||||
// Checkout — order creation is rate-limited: unthrottled retries let a user
|
||||
// burn through a limited-use promo's max_uses without ever paying.
|
||||
router.post('/checkout/order', sensitiveOpsLimiter, ctrl.createOrder);
|
||||
router.post('/checkout/capture', ctrl.captureOrder);
|
||||
router.post('/checkout/cancel', ctrl.cancelOrder);
|
||||
router.post('/checkout/refund', ctrl.refundOrder);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Wraps the Garage daemon: starts it in the background, runs one-time cluster
|
||||
# initialization using the local RPC connection, then hands control back to
|
||||
# the daemon process. Safe to re-run — all operations are idempotent.
|
||||
set -e
|
||||
|
||||
garage server &
|
||||
DAEMON_PID=$!
|
||||
|
||||
echo "Waiting for Garage to be ready..."
|
||||
until garage status > /dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
echo "Garage is up. Running cluster initialization..."
|
||||
|
||||
NODE_ID=$(garage node id 2>/dev/null | head -1 | awk '{print $1}')
|
||||
garage layout assign "$NODE_ID" -z dc1 -c 100G 2>/dev/null || true
|
||||
garage layout apply --version 1 2>/dev/null || true
|
||||
|
||||
garage bucket create "$S3_BUCKET" 2>/dev/null || true
|
||||
|
||||
# --yes skips the interactive confirmation prompt.
|
||||
garage key import "$S3_ACCESS_KEY" "$S3_SECRET_KEY" -n starr-app --yes 2>/dev/null || true
|
||||
|
||||
garage bucket allow "$S3_BUCKET" --read --write --owner --key "$S3_ACCESS_KEY" 2>/dev/null || true
|
||||
|
||||
# Flag file read by the healthcheck — ensures the backend waits for full init.
|
||||
touch /var/lib/garage/meta/.ready
|
||||
echo "Garage init complete."
|
||||
|
||||
wait $DAEMON_PID
|
||||
@@ -59,10 +59,13 @@ async function evaluatePromo(policy, plan, rawCode, effectivePrice = null) {
|
||||
if (rule.expires_at && new Date(rule.expires_at) < new Date())
|
||||
return { valid: false, reason: 'Promo code has expired.' };
|
||||
|
||||
// Count how many completed payments used this code for this plan
|
||||
// Count how many completed payments used this code for this plan. Scoped to
|
||||
// 'completed' only — pending/failed/cancelled attempts must NOT consume a
|
||||
// limited-use code, or anyone can exhaust max_uses by creating orders they
|
||||
// never pay for.
|
||||
if (rule.max_uses != null) {
|
||||
const uses = await mdl_Payments.count({
|
||||
where: { promo_code: code, plan_id: plan.plan_id },
|
||||
where: { promo_code: code, plan_id: plan.plan_id, status: 'completed' },
|
||||
});
|
||||
if (uses >= Number(rule.max_uses))
|
||||
return { valid: false, reason: 'Promo code has reached its usage limit.' };
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
jest.mock('../../models/tiers/tier_categories.mdl', () => ({}));
|
||||
jest.mock('../../models/tiers/tier_plans.mdl', () => ({ findOne: jest.fn() }));
|
||||
jest.mock('../../models/tiers/user_tiers.mdl', () => ({ findOne: jest.fn(), create: jest.fn() }));
|
||||
jest.mock('../../models/tiers/payments.mdl', () => ({ findOne: jest.fn() }));
|
||||
jest.mock('../../models/tiers/payments.mdl', () => ({ findAll: jest.fn() }));
|
||||
jest.mock('../../models/system_badges/system_badges.mdl', () => ({}));
|
||||
jest.mock('../../models/assets/assets.mdl', () => ({}));
|
||||
jest.mock('../../models/notifications/user_notification.mdl', () => ({ create: jest.fn() }));
|
||||
@@ -57,7 +57,7 @@ describe('captureOrder() — declined/incomplete captures must not grant access'
|
||||
|
||||
test('capture.status "DECLINED" marks the payment failed and creates no tier', async () => {
|
||||
const payment = makePayment();
|
||||
mdl_Payments.findOne.mockResolvedValue(payment);
|
||||
mdl_Payments.findAll.mockResolvedValue([payment]);
|
||||
paymentSvc.captureOrder.mockResolvedValue({
|
||||
status: 'COMPLETED', // outer order status can still say COMPLETED
|
||||
purchase_units: [{ payments: { captures: [{ id: 'CAP-1', status: 'DECLINED' }] } }],
|
||||
@@ -77,7 +77,7 @@ describe('captureOrder() — declined/incomplete captures must not grant access'
|
||||
|
||||
test('capture.status "PENDING" (e.g. eCheck review) also withholds access', async () => {
|
||||
const payment = makePayment();
|
||||
mdl_Payments.findOne.mockResolvedValue(payment);
|
||||
mdl_Payments.findAll.mockResolvedValue([payment]);
|
||||
paymentSvc.captureOrder.mockResolvedValue({
|
||||
status: 'COMPLETED',
|
||||
purchase_units: [{ payments: { captures: [{ id: 'CAP-2', status: 'PENDING' }] } }],
|
||||
@@ -97,7 +97,7 @@ describe('captureOrder() — declined/incomplete captures must not grant access'
|
||||
|
||||
test('capture.status "COMPLETED" still grants the tier (control case)', async () => {
|
||||
const payment = makePayment();
|
||||
mdl_Payments.findOne.mockResolvedValue(payment);
|
||||
mdl_Payments.findAll.mockResolvedValue([payment]);
|
||||
mdl_UserTiers.findOne.mockResolvedValue(null); // no existing active tier
|
||||
mdl_UserTiers.create.mockResolvedValue({ tier_id: 99, tier: 'premium', expires_at: new Date() });
|
||||
paymentSvc.captureOrder.mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// tests/controllers/user_groups.controller.test.js
|
||||
|
||||
jest.mock('../../models/users/user_groups.mdl', () => ({
|
||||
mdl_UserGroups: { findOne: jest.fn() },
|
||||
mdl_UserGroupMembers: {},
|
||||
}));
|
||||
|
||||
const { mdl_UserGroups } = require('../../models/users/user_groups.mdl');
|
||||
const { __test__ } = require('../../controllers/admin/user_groups.controller');
|
||||
const { buildSlugGroupCode, generateGroupCode } = __test__;
|
||||
|
||||
describe('buildSlugGroupCode', () => {
|
||||
test('multi-word name -> acronym from first letters', () => {
|
||||
const result = buildSlugGroupCode('Group of Auditors');
|
||||
expect(result).toBe('GA');
|
||||
});
|
||||
|
||||
test('filters out stopwords before building acronym', () => {
|
||||
const result = buildSlugGroupCode('The Sales and Marketing Team');
|
||||
expect(result).toBe('SMT');
|
||||
});
|
||||
|
||||
test('single word -> truncated as-is', () => {
|
||||
const result = buildSlugGroupCode('Sales');
|
||||
expect(result).toBe('SALE');
|
||||
});
|
||||
|
||||
test('respects maxLen', () => {
|
||||
const result = buildSlugGroupCode('Internal Audit Team Extended', 3);
|
||||
expect(result).toBe('IAT');
|
||||
});
|
||||
|
||||
test('strips non-alphanumeric characters per word', () => {
|
||||
const result = buildSlugGroupCode('R&D Ops');
|
||||
expect(result).toBe('RO');
|
||||
});
|
||||
|
||||
test('falls back to GROUP when name is only stopwords/empty after filtering', () => {
|
||||
const result = buildSlugGroupCode('The Of And');
|
||||
expect(result).toBe('GROU');
|
||||
});
|
||||
|
||||
test('falls back to first word when acronym would be 1 letter', () => {
|
||||
const result = buildSlugGroupCode('A Ops');
|
||||
expect(result).toBe('OPS');
|
||||
});
|
||||
|
||||
test('is case-insensitive on input', () => {
|
||||
const result = buildSlugGroupCode('group of auditors');
|
||||
expect(result).toBe('GA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateGroupCode', () => {
|
||||
beforeEach(() => {
|
||||
mdl_UserGroups.findOne.mockReset();
|
||||
});
|
||||
|
||||
test('returns SLUG-XXXX when code is unique on first try', async () => {
|
||||
mdl_UserGroups.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
const code = await generateGroupCode('Group of Auditors');
|
||||
console.log(`[TEST][GROUP CODE] Generated unique code: "${code}" (1 attempt)`);
|
||||
|
||||
expect(code).toMatch(/^GA-[0-9A-F]{4}$/);
|
||||
expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('retries on collision until a unique code is found', async () => {
|
||||
mdl_UserGroups.findOne
|
||||
.mockResolvedValueOnce({ group_code: 'GA-AAAA' })
|
||||
.mockResolvedValueOnce({ group_code: 'GA-BBBB' })
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const code = await generateGroupCode('Group of Auditors');
|
||||
|
||||
expect(code).toMatch(/^GA-[0-9A-F]{4}$/);
|
||||
expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('throws after 5 failed attempts', async () => {
|
||||
mdl_UserGroups.findOne.mockResolvedValue({ group_code: 'GA-AAAA' });
|
||||
|
||||
await expect(generateGroupCode('Group of Auditors')).rejects.toThrow(
|
||||
'Could not generate a unique group code after 5 attempts.'
|
||||
);
|
||||
expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
test('generated code stays within max slug length', async () => {
|
||||
mdl_UserGroups.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
const code = await generateGroupCode('Internal Audit Team For The Whole Organization Wide');
|
||||
const [slug] = code.split('-');
|
||||
console.log(`[TEST][GROUP CODE] Long name -> "${code}" (slug length: ${slug.length})`);
|
||||
|
||||
expect(slug.length).toBeLessThanOrEqual(4); // 4, not 8
|
||||
expect(code.length).toBeLessThanOrEqual(9); // total: XXXX-XXXX
|
||||
});
|
||||
});
|
||||
@@ -131,8 +131,8 @@ describe('createOrder()', () => {
|
||||
await provider.createOrder({ amount: 5, referenceId: 'ref-1' });
|
||||
|
||||
const [, body] = axios.post.mock.calls[1];
|
||||
expect(body.application_context.return_url).toBe('https://app.new-starr.test/plans/checkout');
|
||||
expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/plans/checkout?cancelled=true');
|
||||
expect(body.application_context.return_url).toBe('https://app.new-starr.test/subscriptions/checkout');
|
||||
expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/subscriptions/checkout?cancelled=true');
|
||||
});
|
||||
|
||||
test('honors explicit return/cancel urls when provided', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"cmt_scan":[{"also_scan_build_root":true,"build_root":"lib/bs","scan_dirs":["src/modules/client/hooks/rescript","src/modules/client/hooks/rescript/canvas"]}],"dirs":["src/modules/client/hooks/rescript","src/modules/client/hooks/rescript/canvas"],"generated":[],"pkgs":[["@rescript/react","/home/lash/Desktop/projects/new_starr_app/node_modules/.pnpm/@rescript+react@0.15.0_@rescript+runtime@12.3.0_react-dom@19.2.5_react@19.2.5__react@19.2.5/node_modules/@rescript/react"]],"version":2}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"version": "12.3.0",
|
||||
"bsc_path": "/home/lash/Desktop/projects/new_starr_app/node_modules/.pnpm/@rescript+linux-x64@12.3.0/node_modules/@rescript/linux-x64/bin/bsc.exe",
|
||||
"bsc_hash": "75e3a59c95cc953608fd3dd6ea6ed68141bba4414a20a52f114261b8a48385fa",
|
||||
"rescript_config_hash": "d85ec7cb9b3b80783c8953553f7875d1b9d94eb55dcfd9a57b3ba357830c1934",
|
||||
"runtime_path": "/home/lash/Desktop/projects/new_starr_app/node_modules/.pnpm/@rescript+runtime@12.3.0/node_modules/@rescript/runtime",
|
||||
"generated_at": "1779801549320"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,58 +0,0 @@
|
||||
// Generated by ReScript, PLEASE EDIT WITH CARE
|
||||
|
||||
import * as JsxRuntime from "react/jsx-runtime";
|
||||
|
||||
function AnnotationPanel(props) {
|
||||
let onClose = props.onClose;
|
||||
let onNoteChange = props.onNoteChange;
|
||||
let note = props.note;
|
||||
let lesson = props.lesson;
|
||||
let noteValue = note !== undefined ? note : "";
|
||||
return JsxRuntime.jsxs("div", {
|
||||
children: [
|
||||
JsxRuntime.jsxs("div", {
|
||||
children: [
|
||||
JsxRuntime.jsxs("div", {
|
||||
children: [
|
||||
JsxRuntime.jsx("p", {
|
||||
children: "Annotation",
|
||||
className: "text-xs text-muted-foreground uppercase tracking-widest font-semibold"
|
||||
}),
|
||||
JsxRuntime.jsx("h2", {
|
||||
children: lesson.title,
|
||||
className: "text-lg font-bold mt-0.5"
|
||||
})
|
||||
]
|
||||
}),
|
||||
JsxRuntime.jsx("button", {
|
||||
children: "✕ Close",
|
||||
className: "text-muted-foreground hover:text-foreground transition-colors text-sm",
|
||||
onClick: param => onClose()
|
||||
})
|
||||
],
|
||||
className: "flex items-center justify-between mb-4"
|
||||
}),
|
||||
JsxRuntime.jsx("textarea", {
|
||||
className: "flex-1 w-full resize-none rounded-lg border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
placeholder: "Write your notes for this lesson...",
|
||||
value: noteValue,
|
||||
onChange: e => {
|
||||
let value = e.target.value;
|
||||
onNoteChange(lesson.id, value);
|
||||
}
|
||||
}),
|
||||
JsxRuntime.jsx("p", {
|
||||
children: "Auto-saved to your browser",
|
||||
className: "text-xs text-muted-foreground mt-2"
|
||||
})
|
||||
],
|
||||
className: "flex flex-col h-full"
|
||||
});
|
||||
}
|
||||
|
||||
let make = AnnotationPanel;
|
||||
|
||||
export {
|
||||
make,
|
||||
}
|
||||
/* react/jsx-runtime Not a pure module */
|
||||
@@ -1,43 +0,0 @@
|
||||
@react.component
|
||||
let make = (
|
||||
~lesson: AnnotationTypes.lesson,
|
||||
~note: option<string>,
|
||||
~onNoteChange: (string, string) => unit,
|
||||
~onClose: unit => unit,
|
||||
) => {
|
||||
let noteValue = switch note {
|
||||
| Some(n) => n
|
||||
| None => ""
|
||||
}
|
||||
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{React.string("Annotation")}
|
||||
</p>
|
||||
<h2 className="text-lg font-bold mt-0.5">
|
||||
{React.string(lesson.title)}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={_ => onClose()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
{React.string("✕ Close")}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="flex-1 w-full resize-none rounded-lg border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Write your notes for this lesson..."
|
||||
value={noteValue}
|
||||
onChange={e => {
|
||||
let value = ReactEvent.Form.target(e)["value"]
|
||||
onNoteChange(lesson.id, value)
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{React.string("Auto-saved to your browser")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,29 +0,0 @@
|
||||
// Generated by ReScript, PLEASE EDIT WITH CARE
|
||||
|
||||
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
||||
|
||||
function storageKey(courseId, lessonId) {
|
||||
return `annotation:` + courseId + `:` + lessonId;
|
||||
}
|
||||
|
||||
function saveNote(courseId, lessonId, note) {
|
||||
let key = storageKey(courseId, lessonId);
|
||||
return localStorage.setItem(key, note);
|
||||
}
|
||||
|
||||
function loadNote(courseId, lessonId) {
|
||||
let key = storageKey(courseId, lessonId);
|
||||
return Primitive_option.fromNullable(localStorage.getItem(key));
|
||||
}
|
||||
|
||||
function deleteNote(courseId, lessonId) {
|
||||
return localStorage.removeItem(storageKey(courseId, lessonId));
|
||||
}
|
||||
|
||||
export {
|
||||
storageKey,
|
||||
saveNote,
|
||||
loadNote,
|
||||
deleteNote,
|
||||
}
|
||||
/* No side effect */
|
||||
@@ -1,20 +0,0 @@
|
||||
@val external localStorage: {..} = "localStorage"
|
||||
|
||||
let storageKey = (courseId: string, lessonId: string) =>
|
||||
`annotation:${courseId}:${lessonId}`
|
||||
|
||||
let saveNote = (courseId: string, lessonId: string, note: string) => {
|
||||
let key = storageKey(courseId, lessonId)
|
||||
localStorage["setItem"](key, note)
|
||||
}
|
||||
|
||||
let loadNote = (courseId: string, lessonId: string) => {
|
||||
let key = storageKey(courseId, lessonId)
|
||||
let value: Nullable.t<string> = localStorage["getItem"](key)
|
||||
Nullable.toOption(value)
|
||||
}
|
||||
|
||||
let deleteNote = (courseId: string, lessonId: string) => {
|
||||
let key = storageKey(courseId, lessonId)
|
||||
localStorage["removeItem"](key)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,15 +0,0 @@
|
||||
// Generated by ReScript, PLEASE EDIT WITH CARE
|
||||
|
||||
|
||||
let initialState_notes = {};
|
||||
|
||||
let initialState = {
|
||||
selectedLesson: undefined,
|
||||
isPenOpen: false,
|
||||
notes: initialState_notes
|
||||
};
|
||||
|
||||
export {
|
||||
initialState,
|
||||
}
|
||||
/* No side effect */
|
||||
@@ -1,17 +0,0 @@
|
||||
type lesson = {
|
||||
id: string,
|
||||
title: string,
|
||||
unitId: string,
|
||||
}
|
||||
|
||||
type annotationState = {
|
||||
selectedLesson: option<lesson>,
|
||||
isPenOpen: bool,
|
||||
notes: dict<string>,
|
||||
}
|
||||
|
||||
let initialState = {
|
||||
selectedLesson: None,
|
||||
isPenOpen: false,
|
||||
notes: Dict.make(),
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
// Generated by ReScript, PLEASE EDIT WITH CARE
|
||||
|
||||
import * as JsxRuntime from "react/jsx-runtime";
|
||||
|
||||
function Hello(props) {
|
||||
return JsxRuntime.jsx("div", {
|
||||
children: "ReScript is working!"
|
||||
});
|
||||
}
|
||||
|
||||
let make = Hello;
|
||||
|
||||
export {
|
||||
make,
|
||||
}
|
||||
/* react/jsx-runtime Not a pure module */
|
||||
@@ -1,4 +0,0 @@
|
||||
@react.component
|
||||
let make = () => {
|
||||
<div> {React.string("ReScript is working!")} </div>
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,192 +0,0 @@
|
||||
// Generated by ReScript, PLEASE EDIT WITH CARE
|
||||
|
||||
import * as React from "react";
|
||||
import * as UseSketch from "./useSketch.js";
|
||||
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
||||
import * as JsxRuntime from "react/jsx-runtime";
|
||||
import SketchToolbarJsx from "./SketchToolbar.jsx";
|
||||
|
||||
let make = SketchToolbarJsx;
|
||||
|
||||
let SketchToolbar = {
|
||||
make: make
|
||||
};
|
||||
|
||||
function SketchCanvas(props) {
|
||||
let onClear = props.onClear;
|
||||
let onSave = props.onSave;
|
||||
let color = props.color;
|
||||
let tool = props.tool;
|
||||
let isActive = props.isActive;
|
||||
let canvasRef = React.useRef(null);
|
||||
let isDrawing = React.useRef(false);
|
||||
let lastPoint = React.useRef(undefined);
|
||||
React.useEffect(() => {
|
||||
let el = canvasRef.current;
|
||||
if (!(el == null)) {
|
||||
let parent = el.parentElement;
|
||||
el.width = parent.offsetWidth;
|
||||
el.height = parent.offsetHeight;
|
||||
}
|
||||
}, [isActive]);
|
||||
let getPos = (e, canvas) => {
|
||||
let rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
};
|
||||
let startDraw = e => {
|
||||
let el = canvasRef.current;
|
||||
if (el == null) {
|
||||
return;
|
||||
}
|
||||
isDrawing.current = true;
|
||||
let pos = getPos(e, el);
|
||||
lastPoint.current = pos;
|
||||
if (tool === "Highlighter") {
|
||||
let ctx = el.getContext("2d");
|
||||
let hex = UseSketch.colorToHex(color);
|
||||
let width = UseSketch.toolToWidth(tool, 2.0);
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = hex;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pos.x, pos.y);
|
||||
return;
|
||||
}
|
||||
if (tool !== "Text") {
|
||||
return;
|
||||
}
|
||||
let text = el.ownerDocument.defaultView.prompt("Enter text:", "");
|
||||
if (text == null) {
|
||||
return;
|
||||
}
|
||||
let ctx$1 = el.getContext("2d");
|
||||
let hex$1 = UseSketch.colorToHex(color);
|
||||
ctx$1.globalAlpha = 1.0;
|
||||
ctx$1.fillStyle = hex$1;
|
||||
ctx$1.font = "bold 16px sans-serif";
|
||||
ctx$1.fillText(text, pos.x, pos.y);
|
||||
};
|
||||
let draw = e => {
|
||||
if (!(isDrawing.current && tool !== "Text")) {
|
||||
return;
|
||||
}
|
||||
let el = canvasRef.current;
|
||||
if (el == null) {
|
||||
return;
|
||||
}
|
||||
let last = lastPoint.current;
|
||||
if (last === undefined) {
|
||||
return;
|
||||
}
|
||||
let ctx = el.getContext("2d");
|
||||
let pos = getPos(e, el);
|
||||
let hex = UseSketch.colorToHex(color);
|
||||
let width = UseSketch.toolToWidth(tool, 2.0);
|
||||
let exit = 0;
|
||||
switch (tool) {
|
||||
case "Highlighter" :
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
break;
|
||||
case "Pen" :
|
||||
case "Text" :
|
||||
exit = 1;
|
||||
break;
|
||||
}
|
||||
if (exit === 1) {
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.strokeStyle = hex;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(last.x, last.y);
|
||||
ctx.quadraticCurveTo(last.x + (pos.x - last.x) / 2.0, last.y + (pos.y - last.y) / 2.0, pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
lastPoint.current = pos;
|
||||
};
|
||||
let stopDraw = _e => {
|
||||
if (tool === "Highlighter") {
|
||||
let el = canvasRef.current;
|
||||
if (!(el == null)) {
|
||||
let ctx = el.getContext("2d");
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
}
|
||||
isDrawing.current = false;
|
||||
lastPoint.current = undefined;
|
||||
};
|
||||
let handleClear = () => {
|
||||
let el = canvasRef.current;
|
||||
if (el == null) {
|
||||
return;
|
||||
}
|
||||
let ctx = el.getContext("2d");
|
||||
ctx.clearRect(0, 0, el.width, el.height);
|
||||
onClear();
|
||||
};
|
||||
let handleSave = () => {
|
||||
let el = canvasRef.current;
|
||||
if (el == null) {
|
||||
return;
|
||||
}
|
||||
let dataUrl = el.toDataURL("image/png");
|
||||
let link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = "sketch.png";
|
||||
link.click();
|
||||
onSave();
|
||||
};
|
||||
if (isActive) {
|
||||
return JsxRuntime.jsxs("div", {
|
||||
children: [
|
||||
JsxRuntime.jsx(make, {
|
||||
state: {
|
||||
isActive: isActive,
|
||||
tool: tool,
|
||||
color: color,
|
||||
strokeWidth: 2.0
|
||||
},
|
||||
onSetTool: props.onSetTool,
|
||||
onSetColor: props.onSetColor,
|
||||
onClear: handleClear,
|
||||
onSave: handleSave,
|
||||
onClose: props.onClose
|
||||
}),
|
||||
JsxRuntime.jsx("canvas", {
|
||||
ref: Primitive_option.some(canvasRef),
|
||||
className: "w-full h-full cursor-crosshair",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
onMouseDown: startDraw,
|
||||
onMouseLeave: stopDraw,
|
||||
onMouseMove: draw,
|
||||
onMouseUp: stopDraw
|
||||
})
|
||||
],
|
||||
className: "absolute inset-0 z-10",
|
||||
style: {
|
||||
pointerEvents: isActive ? "all" : "none"
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let make$1 = SketchCanvas;
|
||||
|
||||
export {
|
||||
SketchToolbar,
|
||||
make$1 as make,
|
||||
}
|
||||
/* make Not a pure module */
|
||||
@@ -1,198 +0,0 @@
|
||||
module SketchToolbar = {
|
||||
@module("./SketchToolbar.jsx") @react.component
|
||||
external make: (
|
||||
~state: UseSketch.sketchState,
|
||||
~onSetTool: UseSketch.tool => unit,
|
||||
~onSetColor: UseSketch.color => unit,
|
||||
~onClear: unit => unit,
|
||||
~onSave: unit => unit,
|
||||
~onClose: unit => unit,
|
||||
) => React.element = "default"
|
||||
}
|
||||
|
||||
type point = {x: float, y: float}
|
||||
|
||||
@val external document: {..} = "document"
|
||||
external asCanvas: Dom.element => {..} = "%identity"
|
||||
|
||||
@react.component
|
||||
let make = (
|
||||
~isActive: bool,
|
||||
~tool: UseSketch.tool,
|
||||
~color: UseSketch.color,
|
||||
~onSave: unit => unit,
|
||||
~onClear: unit => unit,
|
||||
~onClose: unit => unit,
|
||||
~onSetTool: UseSketch.tool => unit,
|
||||
~onSetColor: UseSketch.color => unit,
|
||||
) => {
|
||||
let canvasRef: React.ref<Nullable.t<Dom.element>> = React.useRef(Nullable.null)
|
||||
let isDrawing = React.useRef(false)
|
||||
let lastPoint = React.useRef(None)
|
||||
|
||||
React.useEffect1(() => {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
let parent = canvas["parentElement"]
|
||||
canvas["width"] = parent["offsetWidth"]
|
||||
canvas["height"] = parent["offsetHeight"]
|
||||
}
|
||||
None
|
||||
}, [isActive])
|
||||
|
||||
let getPos = (e: JsxEvent.Mouse.t, canvas) => {
|
||||
let rect = canvas["getBoundingClientRect"]()
|
||||
{
|
||||
x: Float.fromInt(JsxEvent.Mouse.clientX(e)) -. rect["left"],
|
||||
y: Float.fromInt(JsxEvent.Mouse.clientY(e)) -. rect["top"],
|
||||
}
|
||||
}
|
||||
|
||||
let startDraw = (e: JsxEvent.Mouse.t) => {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
isDrawing.current = true
|
||||
let pos = getPos(e, canvas)
|
||||
lastPoint.current = Some(pos)
|
||||
if tool === UseSketch.Highlighter {
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let hex = UseSketch.colorToHex(color)
|
||||
let width = UseSketch.toolToWidth(tool, 2.0)
|
||||
ctx["globalAlpha"] = 0.25
|
||||
ctx["globalCompositeOperation"] = "source-over"
|
||||
ctx["strokeStyle"] = hex
|
||||
ctx["lineWidth"] = width
|
||||
ctx["lineCap"] = "round"
|
||||
ctx["lineJoin"] = "round"
|
||||
let _ = ctx["beginPath"]()
|
||||
let _ = ctx["moveTo"](pos.x, pos.y)
|
||||
} else if tool === UseSketch.Text {
|
||||
let text = canvas["ownerDocument"]["defaultView"]["prompt"]("Enter text:", "")
|
||||
switch Nullable.toOption(text) {
|
||||
| None => ()
|
||||
| Some(t) =>
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let hex = UseSketch.colorToHex(color)
|
||||
ctx["globalAlpha"] = 1.0
|
||||
ctx["fillStyle"] = hex
|
||||
ctx["font"] = "bold 16px sans-serif"
|
||||
let _ = ctx["fillText"](t, pos.x, pos.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let draw = (e: JsxEvent.Mouse.t) => {
|
||||
if isDrawing.current && tool !== UseSketch.Text {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
switch lastPoint.current {
|
||||
| None => ()
|
||||
| Some(last) =>
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let pos = getPos(e, canvas)
|
||||
let hex = UseSketch.colorToHex(color)
|
||||
let width = UseSketch.toolToWidth(tool, 2.0)
|
||||
switch tool {
|
||||
| UseSketch.Highlighter =>
|
||||
let _ = ctx["lineTo"](pos.x, pos.y)
|
||||
let _ = ctx["stroke"]()
|
||||
| _ =>
|
||||
ctx["globalAlpha"] = 1.0
|
||||
ctx["strokeStyle"] = hex
|
||||
ctx["lineWidth"] = width
|
||||
ctx["lineCap"] = "round"
|
||||
ctx["lineJoin"] = "round"
|
||||
let _ = ctx["beginPath"]()
|
||||
let _ = ctx["moveTo"](last.x, last.y)
|
||||
let _ = ctx["quadraticCurveTo"](
|
||||
last.x +. (pos.x -. last.x) /. 2.0,
|
||||
last.y +. (pos.y -. last.y) /. 2.0,
|
||||
pos.x,
|
||||
pos.y,
|
||||
)
|
||||
let _ = ctx["stroke"]()
|
||||
}
|
||||
lastPoint.current = Some(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stopDraw = (_e: JsxEvent.Mouse.t) => {
|
||||
if tool === UseSketch.Highlighter {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let _ = ctx["stroke"]()
|
||||
ctx["globalAlpha"] = 1.0
|
||||
ctx["globalCompositeOperation"] = "source-over"
|
||||
}
|
||||
}
|
||||
isDrawing.current = false
|
||||
lastPoint.current = None
|
||||
}
|
||||
|
||||
let handleClear = () => {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let _ = ctx["clearRect"](0, 0, canvas["width"], canvas["height"])
|
||||
onClear()
|
||||
}
|
||||
}
|
||||
|
||||
let handleSave = () => {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
let dataUrl = canvas["toDataURL"]("image/png")
|
||||
let link = document["createElement"]("a")
|
||||
link["href"] = dataUrl
|
||||
link["download"] = "sketch.png"
|
||||
let _ = link["click"]()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
|
||||
if !isActive {
|
||||
React.null
|
||||
} else {
|
||||
<div className="absolute inset-0 z-10" style={{pointerEvents: isActive ? "all" : "none"}}>
|
||||
<SketchToolbar.make
|
||||
state={{
|
||||
UseSketch.isActive,
|
||||
tool,
|
||||
color,
|
||||
strokeWidth: 2.0,
|
||||
}}
|
||||
onSetTool={onSetTool}
|
||||
onSetColor={onSetColor}
|
||||
onClear={handleClear}
|
||||
onSave={handleSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef->ReactDOM.Ref.domRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="w-full h-full cursor-crosshair"
|
||||
onMouseDown={startDraw}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDraw}
|
||||
onMouseLeave={stopDraw}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,14 +0,0 @@
|
||||
// Generated by ReScript, PLEASE EDIT WITH CARE
|
||||
|
||||
import SketchToolbarJsx from "./SketchToolbar.jsx";
|
||||
|
||||
let make = SketchToolbarJsx;
|
||||
|
||||
let SketchToolbar = {
|
||||
make: make
|
||||
};
|
||||
|
||||
export {
|
||||
SketchToolbar,
|
||||
}
|
||||
/* make Not a pure module */
|
||||
@@ -1,11 +0,0 @@
|
||||
module SketchToolbar = {
|
||||
@module("./SketchToolbar.jsx") @react.component
|
||||
external make: (
|
||||
~state: UseSketch.sketchState,
|
||||
~onSetTool: UseSketch.tool => unit,
|
||||
~onSetColor: UseSketch.color => unit,
|
||||
~onClear: unit => unit,
|
||||
~onSave: unit => unit,
|
||||
~onClose: unit => unit,
|
||||
) => React.element = "default"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,106 +0,0 @@
|
||||
// Generated by ReScript, PLEASE EDIT WITH CARE
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
let initialState = {
|
||||
isActive: false,
|
||||
tool: "Pen",
|
||||
color: "Black",
|
||||
strokeWidth: 2.0
|
||||
};
|
||||
|
||||
function colorToHex(color) {
|
||||
switch (color) {
|
||||
case "Black" :
|
||||
return "#000000";
|
||||
case "Red" :
|
||||
return "#ef4444";
|
||||
case "Blue" :
|
||||
return "#3b82f6";
|
||||
case "Yellow" :
|
||||
return "#eab308";
|
||||
}
|
||||
}
|
||||
|
||||
function toolToOpacity(tool) {
|
||||
switch (tool) {
|
||||
case "Highlighter" :
|
||||
return 0.3;
|
||||
case "Pen" :
|
||||
case "Text" :
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
function isHighlighter(tool) {
|
||||
switch (tool) {
|
||||
case "Highlighter" :
|
||||
return true;
|
||||
case "Pen" :
|
||||
case "Text" :
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toolToWidth(tool, base) {
|
||||
switch (tool) {
|
||||
case "Highlighter" :
|
||||
return base * 8.0;
|
||||
case "Pen" :
|
||||
case "Text" :
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function use() {
|
||||
let match = React.useState(() => initialState);
|
||||
let setState = match[1];
|
||||
let activate = () => setState(prev => ({
|
||||
isActive: true,
|
||||
tool: prev.tool,
|
||||
color: prev.color,
|
||||
strokeWidth: prev.strokeWidth
|
||||
}));
|
||||
let deactivate = () => setState(prev => ({
|
||||
isActive: false,
|
||||
tool: prev.tool,
|
||||
color: prev.color,
|
||||
strokeWidth: prev.strokeWidth
|
||||
}));
|
||||
let toggle = () => setState(prev => ({
|
||||
isActive: !prev.isActive,
|
||||
tool: prev.tool,
|
||||
color: prev.color,
|
||||
strokeWidth: prev.strokeWidth
|
||||
}));
|
||||
let setTool = tool => setState(prev => ({
|
||||
isActive: prev.isActive,
|
||||
tool: tool,
|
||||
color: prev.color,
|
||||
strokeWidth: prev.strokeWidth
|
||||
}));
|
||||
let setColor = color => setState(prev => ({
|
||||
isActive: prev.isActive,
|
||||
tool: prev.tool,
|
||||
color: color,
|
||||
strokeWidth: prev.strokeWidth
|
||||
}));
|
||||
return [
|
||||
match[0],
|
||||
activate,
|
||||
deactivate,
|
||||
toggle,
|
||||
setTool,
|
||||
setColor
|
||||
];
|
||||
}
|
||||
|
||||
export {
|
||||
initialState,
|
||||
colorToHex,
|
||||
toolToOpacity,
|
||||
isHighlighter,
|
||||
toolToWidth,
|
||||
use,
|
||||
}
|
||||
/* react Not a pure module */
|
||||
@@ -1,67 +0,0 @@
|
||||
type tool =
|
||||
| Pen
|
||||
| Highlighter
|
||||
| Text
|
||||
|
||||
type color =
|
||||
| Black
|
||||
| Red
|
||||
| Blue
|
||||
| Yellow
|
||||
|
||||
type sketchState = {
|
||||
isActive: bool,
|
||||
tool: tool,
|
||||
color: color,
|
||||
strokeWidth: float,
|
||||
}
|
||||
|
||||
let initialState: sketchState = {
|
||||
isActive: false,
|
||||
tool: Pen,
|
||||
color: Black,
|
||||
strokeWidth: 2.0,
|
||||
}
|
||||
|
||||
let colorToHex = (color: color) =>
|
||||
switch color {
|
||||
| Black => "#000000"
|
||||
| Red => "#ef4444"
|
||||
| Blue => "#3b82f6"
|
||||
| Yellow => "#eab308"
|
||||
}
|
||||
|
||||
let toolToOpacity = (tool: tool) =>
|
||||
switch tool {
|
||||
| Highlighter => 0.3
|
||||
| Pen | Text => 1.0
|
||||
}
|
||||
|
||||
let isHighlighter = (tool: tool) =>
|
||||
switch tool {
|
||||
| Highlighter => true
|
||||
| Pen | Text => false
|
||||
}
|
||||
|
||||
let toolToWidth = (tool: tool, base: float) =>
|
||||
switch tool {
|
||||
| Highlighter => base *. 8.0
|
||||
| Pen => base
|
||||
| Text => base
|
||||
}
|
||||
|
||||
let use = () => {
|
||||
let (state, setState) = React.useState(() => initialState)
|
||||
|
||||
let activate = () => setState(prev => {...prev, isActive: true})
|
||||
|
||||
let deactivate = () => setState(prev => {...prev, isActive: false})
|
||||
|
||||
let toggle = () => setState(prev => {...prev, isActive: !prev.isActive})
|
||||
|
||||
let setTool = (tool: tool) => setState(prev => {...prev, tool})
|
||||
|
||||
let setColor = (color: color) => setState(prev => {...prev, color})
|
||||
|
||||
(state, activate, deactivate, toggle, setTool, setColor)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,53 +0,0 @@
|
||||
// Generated by ReScript, PLEASE EDIT WITH CARE
|
||||
|
||||
import * as React from "react";
|
||||
import * as AnnotationTypes from "./AnnotationTypes.js";
|
||||
import * as AnnotationStorage from "./AnnotationStorage.js";
|
||||
|
||||
function use(courseId) {
|
||||
let match = React.useState(() => AnnotationTypes.initialState);
|
||||
let setState = match[1];
|
||||
let state = match[0];
|
||||
let selectLesson = lesson => {
|
||||
let savedNote = AnnotationStorage.loadNote(courseId, lesson.id);
|
||||
let notes = {};
|
||||
if (savedNote !== undefined) {
|
||||
notes[lesson.id] = savedNote;
|
||||
}
|
||||
setState(param => ({
|
||||
selectedLesson: lesson,
|
||||
isPenOpen: true,
|
||||
notes: notes
|
||||
}));
|
||||
};
|
||||
let updateNote = (lessonId, note) => {
|
||||
AnnotationStorage.saveNote(courseId, lessonId, note);
|
||||
setState(prev => {
|
||||
let notes = {};
|
||||
notes[lessonId] = note;
|
||||
return {
|
||||
selectedLesson: prev.selectedLesson,
|
||||
isPenOpen: prev.isPenOpen,
|
||||
notes: notes
|
||||
};
|
||||
});
|
||||
};
|
||||
let closePen = () => setState(prev => ({
|
||||
selectedLesson: undefined,
|
||||
isPenOpen: false,
|
||||
notes: prev.notes
|
||||
}));
|
||||
let getNote = lessonId => state.notes[lessonId];
|
||||
return [
|
||||
state,
|
||||
selectLesson,
|
||||
updateNote,
|
||||
closePen,
|
||||
getNote
|
||||
];
|
||||
}
|
||||
|
||||
export {
|
||||
use,
|
||||
}
|
||||
/* react Not a pure module */
|
||||
@@ -1,38 +0,0 @@
|
||||
@val external window: {..} = "window"
|
||||
|
||||
let use = (courseId: string) => {
|
||||
let (state, setState) = React.useState(() => AnnotationTypes.initialState)
|
||||
|
||||
let selectLesson = (lesson: AnnotationTypes.lesson) => {
|
||||
let savedNote = AnnotationStorage.loadNote(courseId, lesson.id)
|
||||
let notes = Dict.make()
|
||||
switch savedNote {
|
||||
| Some(note) => Dict.set(notes, lesson.id, note)
|
||||
| None => ()
|
||||
}
|
||||
setState(_ => {
|
||||
selectedLesson: Some(lesson),
|
||||
isPenOpen: true,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
let updateNote = (lessonId: string, note: string) => {
|
||||
AnnotationStorage.saveNote(courseId, lessonId, note)
|
||||
setState(prev => {
|
||||
let notes = Dict.make()
|
||||
Dict.set(notes, lessonId, note)
|
||||
{...prev, notes}
|
||||
})
|
||||
}
|
||||
|
||||
let closePen = () => {
|
||||
setState(prev => {...prev, isPenOpen: false, selectedLesson: None})
|
||||
}
|
||||
|
||||
let getNote = (lessonId: string) => {
|
||||
Dict.get(state.notes, lessonId)
|
||||
}
|
||||
|
||||
(state, selectLesson, updateNote, closePen, getNote)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
@react.component
|
||||
let make = (
|
||||
~lesson: AnnotationTypes.lesson,
|
||||
~note: option<string>,
|
||||
~onNoteChange: (string, string) => unit,
|
||||
~onClose: unit => unit,
|
||||
) => {
|
||||
let noteValue = switch note {
|
||||
| Some(n) => n
|
||||
| None => ""
|
||||
}
|
||||
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{React.string("Annotation")}
|
||||
</p>
|
||||
<h2 className="text-lg font-bold mt-0.5">
|
||||
{React.string(lesson.title)}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={_ => onClose()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
{React.string("✕ Close")}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="flex-1 w-full resize-none rounded-lg border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Write your notes for this lesson..."
|
||||
value={noteValue}
|
||||
onChange={e => {
|
||||
let value = ReactEvent.Form.target(e)["value"]
|
||||
onNoteChange(lesson.id, value)
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{React.string("Auto-saved to your browser")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,20 +0,0 @@
|
||||
@val external localStorage: {..} = "localStorage"
|
||||
|
||||
let storageKey = (courseId: string, lessonId: string) =>
|
||||
`annotation:${courseId}:${lessonId}`
|
||||
|
||||
let saveNote = (courseId: string, lessonId: string, note: string) => {
|
||||
let key = storageKey(courseId, lessonId)
|
||||
localStorage["setItem"](key, note)
|
||||
}
|
||||
|
||||
let loadNote = (courseId: string, lessonId: string) => {
|
||||
let key = storageKey(courseId, lessonId)
|
||||
let value: Nullable.t<string> = localStorage["getItem"](key)
|
||||
Nullable.toOption(value)
|
||||
}
|
||||
|
||||
let deleteNote = (courseId: string, lessonId: string) => {
|
||||
let key = storageKey(courseId, lessonId)
|
||||
localStorage["removeItem"](key)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,17 +0,0 @@
|
||||
type lesson = {
|
||||
id: string,
|
||||
title: string,
|
||||
unitId: string,
|
||||
}
|
||||
|
||||
type annotationState = {
|
||||
selectedLesson: option<lesson>,
|
||||
isPenOpen: bool,
|
||||
notes: dict<string>,
|
||||
}
|
||||
|
||||
let initialState = {
|
||||
selectedLesson: None,
|
||||
isPenOpen: false,
|
||||
notes: Dict.make(),
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@react.component
|
||||
let make = () => {
|
||||
<div> {React.string("ReScript is working!")} </div>
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,198 +0,0 @@
|
||||
module SketchToolbar = {
|
||||
@module("./SketchToolbar.jsx") @react.component
|
||||
external make: (
|
||||
~state: UseSketch.sketchState,
|
||||
~onSetTool: UseSketch.tool => unit,
|
||||
~onSetColor: UseSketch.color => unit,
|
||||
~onClear: unit => unit,
|
||||
~onSave: unit => unit,
|
||||
~onClose: unit => unit,
|
||||
) => React.element = "default"
|
||||
}
|
||||
|
||||
type point = {x: float, y: float}
|
||||
|
||||
@val external document: {..} = "document"
|
||||
external asCanvas: Dom.element => {..} = "%identity"
|
||||
|
||||
@react.component
|
||||
let make = (
|
||||
~isActive: bool,
|
||||
~tool: UseSketch.tool,
|
||||
~color: UseSketch.color,
|
||||
~onSave: unit => unit,
|
||||
~onClear: unit => unit,
|
||||
~onClose: unit => unit,
|
||||
~onSetTool: UseSketch.tool => unit,
|
||||
~onSetColor: UseSketch.color => unit,
|
||||
) => {
|
||||
let canvasRef: React.ref<Nullable.t<Dom.element>> = React.useRef(Nullable.null)
|
||||
let isDrawing = React.useRef(false)
|
||||
let lastPoint = React.useRef(None)
|
||||
|
||||
React.useEffect1(() => {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
let parent = canvas["parentElement"]
|
||||
canvas["width"] = parent["offsetWidth"]
|
||||
canvas["height"] = parent["offsetHeight"]
|
||||
}
|
||||
None
|
||||
}, [isActive])
|
||||
|
||||
let getPos = (e: JsxEvent.Mouse.t, canvas) => {
|
||||
let rect = canvas["getBoundingClientRect"]()
|
||||
{
|
||||
x: Float.fromInt(JsxEvent.Mouse.clientX(e)) -. rect["left"],
|
||||
y: Float.fromInt(JsxEvent.Mouse.clientY(e)) -. rect["top"],
|
||||
}
|
||||
}
|
||||
|
||||
let startDraw = (e: JsxEvent.Mouse.t) => {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
isDrawing.current = true
|
||||
let pos = getPos(e, canvas)
|
||||
lastPoint.current = Some(pos)
|
||||
if tool === UseSketch.Highlighter {
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let hex = UseSketch.colorToHex(color)
|
||||
let width = UseSketch.toolToWidth(tool, 2.0)
|
||||
ctx["globalAlpha"] = 0.25
|
||||
ctx["globalCompositeOperation"] = "source-over"
|
||||
ctx["strokeStyle"] = hex
|
||||
ctx["lineWidth"] = width
|
||||
ctx["lineCap"] = "round"
|
||||
ctx["lineJoin"] = "round"
|
||||
let _ = ctx["beginPath"]()
|
||||
let _ = ctx["moveTo"](pos.x, pos.y)
|
||||
} else if tool === UseSketch.Text {
|
||||
let text = canvas["ownerDocument"]["defaultView"]["prompt"]("Enter text:", "")
|
||||
switch Nullable.toOption(text) {
|
||||
| None => ()
|
||||
| Some(t) =>
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let hex = UseSketch.colorToHex(color)
|
||||
ctx["globalAlpha"] = 1.0
|
||||
ctx["fillStyle"] = hex
|
||||
ctx["font"] = "bold 16px sans-serif"
|
||||
let _ = ctx["fillText"](t, pos.x, pos.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let draw = (e: JsxEvent.Mouse.t) => {
|
||||
if isDrawing.current && tool !== UseSketch.Text {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
switch lastPoint.current {
|
||||
| None => ()
|
||||
| Some(last) =>
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let pos = getPos(e, canvas)
|
||||
let hex = UseSketch.colorToHex(color)
|
||||
let width = UseSketch.toolToWidth(tool, 2.0)
|
||||
switch tool {
|
||||
| UseSketch.Highlighter =>
|
||||
let _ = ctx["lineTo"](pos.x, pos.y)
|
||||
let _ = ctx["stroke"]()
|
||||
| _ =>
|
||||
ctx["globalAlpha"] = 1.0
|
||||
ctx["strokeStyle"] = hex
|
||||
ctx["lineWidth"] = width
|
||||
ctx["lineCap"] = "round"
|
||||
ctx["lineJoin"] = "round"
|
||||
let _ = ctx["beginPath"]()
|
||||
let _ = ctx["moveTo"](last.x, last.y)
|
||||
let _ = ctx["quadraticCurveTo"](
|
||||
last.x +. (pos.x -. last.x) /. 2.0,
|
||||
last.y +. (pos.y -. last.y) /. 2.0,
|
||||
pos.x,
|
||||
pos.y,
|
||||
)
|
||||
let _ = ctx["stroke"]()
|
||||
}
|
||||
lastPoint.current = Some(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stopDraw = (_e: JsxEvent.Mouse.t) => {
|
||||
if tool === UseSketch.Highlighter {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let _ = ctx["stroke"]()
|
||||
ctx["globalAlpha"] = 1.0
|
||||
ctx["globalCompositeOperation"] = "source-over"
|
||||
}
|
||||
}
|
||||
isDrawing.current = false
|
||||
lastPoint.current = None
|
||||
}
|
||||
|
||||
let handleClear = () => {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
let ctx = canvas["getContext"]("2d")
|
||||
let _ = ctx["clearRect"](0, 0, canvas["width"], canvas["height"])
|
||||
onClear()
|
||||
}
|
||||
}
|
||||
|
||||
let handleSave = () => {
|
||||
switch canvasRef.current->Nullable.toOption {
|
||||
| None => ()
|
||||
| Some(el) =>
|
||||
let canvas = asCanvas(el)
|
||||
let dataUrl = canvas["toDataURL"]("image/png")
|
||||
let link = document["createElement"]("a")
|
||||
link["href"] = dataUrl
|
||||
link["download"] = "sketch.png"
|
||||
let _ = link["click"]()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
|
||||
if !isActive {
|
||||
React.null
|
||||
} else {
|
||||
<div className="absolute inset-0 z-10" style={{pointerEvents: isActive ? "all" : "none"}}>
|
||||
<SketchToolbar.make
|
||||
state={{
|
||||
UseSketch.isActive,
|
||||
tool,
|
||||
color,
|
||||
strokeWidth: 2.0,
|
||||
}}
|
||||
onSetTool={onSetTool}
|
||||
onSetColor={onSetColor}
|
||||
onClear={handleClear}
|
||||
onSave={handleSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef->ReactDOM.Ref.domRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="w-full h-full cursor-crosshair"
|
||||
onMouseDown={startDraw}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDraw}
|
||||
onMouseLeave={stopDraw}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +0,0 @@
|
||||
module SketchToolbar = {
|
||||
@module("./SketchToolbar.jsx") @react.component
|
||||
external make: (
|
||||
~state: UseSketch.sketchState,
|
||||
~onSetTool: UseSketch.tool => unit,
|
||||
~onSetColor: UseSketch.color => unit,
|
||||
~onClear: unit => unit,
|
||||
~onSave: unit => unit,
|
||||
~onClose: unit => unit,
|
||||
) => React.element = "default"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
@val external window: {..} = "window"
|
||||
|
||||
let use = (courseId: string) => {
|
||||
let (state, setState) = React.useState(() => AnnotationTypes.initialState)
|
||||
|
||||
let selectLesson = (lesson: AnnotationTypes.lesson) => {
|
||||
let savedNote = AnnotationStorage.loadNote(courseId, lesson.id)
|
||||
let notes = Dict.make()
|
||||
switch savedNote {
|
||||
| Some(note) => Dict.set(notes, lesson.id, note)
|
||||
| None => ()
|
||||
}
|
||||
setState(_ => {
|
||||
selectedLesson: Some(lesson),
|
||||
isPenOpen: true,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
let updateNote = (lessonId: string, note: string) => {
|
||||
AnnotationStorage.saveNote(courseId, lessonId, note)
|
||||
setState(prev => {
|
||||
let notes = Dict.make()
|
||||
Dict.set(notes, lessonId, note)
|
||||
{...prev, notes}
|
||||
})
|
||||
}
|
||||
|
||||
let closePen = () => {
|
||||
setState(prev => {...prev, isPenOpen: false, selectedLesson: None})
|
||||
}
|
||||
|
||||
let getNote = (lessonId: string) => {
|
||||
Dict.get(state.notes, lessonId)
|
||||
}
|
||||
|
||||
(state, selectLesson, updateNote, closePen, getNote)
|
||||
}
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user