From dfe50a2b42f71747a1ffadf18867b98f83420f3a Mon Sep 17 00:00:00 2001 From: boomNDS Date: Sun, 7 Dec 2025 15:22:57 +0700 Subject: [PATCH] chore: improve local dev setup --- .tool-versions | 2 + README.md | 12 ++ docker-compose.yml | 44 +++++++ frontend/README.md | 31 ++--- server/.env.example | 44 +++++++ server/README.md | 23 ++-- server/db/init.go | 33 ++++- .../20230812_add_calendar_accounts/main.go | 34 +++-- server/scripts/seed_demo/main.go | 122 ++++++++++++++++++ server/services/gcloud/tasks.go | 19 +++ 10 files changed, 323 insertions(+), 41 deletions(-) create mode 100644 .tool-versions create mode 100644 docker-compose.yml create mode 100644 server/.env.example create mode 100644 server/scripts/seed_demo/main.go diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..b4e6cef6 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +golang 1.20.14 +nodejs 18.20.2 diff --git a/README.md b/README.md index b0b1d563..9a5a9a78 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,18 @@ Built with [Vue 2](https://github.com/vuejs/vue), [MongoDB](https://github.com/m - Export availability as CSV - Only show responses to event creator +## Local development +- Prereqs: Node 18+, Go 1.20+, MongoDB on `localhost:27017`, GCP service account key JSON. +- Backend: create `server/.env` (includes `SERVICE_ACCOUNT_KEY_PATH` and any Stripe/OAuth/email keys), start Mongo, then `cd server && air` (or `go run main.go`) to run `http://localhost:3002/api`. Cloud Tasks is skipped if `SERVICE_ACCOUNT_KEY_PATH` is unset or the file is missing. +- Frontend: `cd frontend && npm install && npm run serve` to run `http://localhost:8080` against the local API; `npm run build` to serve from Go. +- Detailed steps and env samples: `docs/local-dev.md`. +- Docker options: + - Hybrid (Mongo only): `docker compose up -d mongo`, then run Go/Vue locally after installing deps. + - Full stack: `docker compose up --build` spins up Mongo, the Go API, and the Vue dev server (see `docs/local-dev.md` for env/secrets prep). +- Tool versions: `.tool-versions` pins `golang 1.20.14` and `nodejs 18.20.2` (works with asdf or as a reference) to avoid mismatched runtimes with the Go module and Vue CLI 5 stack. +- asdf + zsh: `asdf plugin add golang nodejs` (if not present), `asdf install`, ensure `source "$HOME/.asdf/asdf.sh"` is in `~/.zshrc`, and add `export PATH="$PATH:$(go env GOPATH)/bin"` so Go-installed tools (e.g., `air`) are found. +- Seed demo data (optional): `cd server/scripts/seed_demo && MONGODB_URI=mongodb://localhost:27017 go run main.go` creates a demo user/event for local testing. Avoid running other scripts in `server/scripts/*` unless you know the migration you need. + ## Self-hosting Coming soon... diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..0730a91a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +version: "3.9" + +services: + mongo: + image: mongo:6 + restart: unless-stopped + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + + server: + image: golang:1.20 + working_dir: /app/server + command: sh -c "go mod download && go run main.go" + env_file: + - server/.env + environment: + - MONGODB_URI=mongodb://mongo:27017 + - SERVICE_ACCOUNT_KEY_PATH=/secrets/service_account_key.json + volumes: + - ./:/app + # Mount your GCP service account key JSON here + - ./secrets/service_account_key.json:/secrets/service_account_key.json:ro + ports: + - "3002:3002" + depends_on: + - mongo + + frontend: + image: node:18 + working_dir: /app/frontend + command: sh -c "npm install && npm run serve -- --host 0.0.0.0 --port 8080" + environment: + - CHOKIDAR_USEPOLLING=true + volumes: + - ./:/app + ports: + - "8080:8080" + depends_on: + - server + +volumes: + mongo_data: diff --git a/frontend/README.md b/frontend/README.md index fee552b9..595d1a1b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,23 +1,18 @@ -# timeful +# Timeful frontend -## Project setup +Vue 2 app for Timeful. -``` -npm install -``` +## Quick start +- Install: `npm install` +- Dev server: `npm run serve` (http://localhost:8080 or 8081 if 8080 is busy) +- API base (dev): `http://localhost:3002/api` (see `src/constants.js`) +- Build for production: `npm run build` (Go API serves `frontend/dist`) -### Compiles and hot-reloads for development +## Backend API docs +- http://localhost:3002/swagger/index.html (server must be running) -``` -npm run serve -``` +## Optional demo data +- From repo root: `cd server/scripts/seed_demo && MONGODB_URI=mongodb://localhost:27017 go run main.go` (adds demo user + event) -### Compiles and minifies for production - -``` -npm run build -``` - -### Customize configuration - -See [Configuration Reference](https://cli.vuejs.org/config/). +## Customize configuration +- Vue CLI reference: https://cli.vuejs.org/config/ diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 00000000..949784b8 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,44 @@ +# Core +SERVICE_ACCOUNT_KEY_PATH=/secrets/service_account_key.json +MONGODB_URI=mongodb://mongo:27017 +ENCRYPTION_KEY=32_char_encryption_key_here + +# OAuth / clients +CLIENT_ID=google_oauth_client_id +CLIENT_SECRET=google_oauth_client_secret +MICROSOFT_CLIENT_ID=microsoft_client_id +MICROSOFT_CLIENT_SECRET=microsoft_client_secret +IOS_CLIENT_ID=ios_client_id_optional +ANDROID_CLIENT_ID=android_client_id_optional + +# Stripe (optional unless you hit billing paths) +STRIPE_API_KEY=sk_test_xxx +STRIPE_MONTHLY_PRICE_ID=price_xxx +STRIPE_MONTHLY_STUDENT_PRICE_ID=price_xxx +STRIPE_LIFETIME_STUDENT_PRICE_ID=price_xxx +STRIPE_YEARLY_PRICE_ID=price_xxx +STRIPE_LIFETIME_PRICE_ID=price_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx + +# Email / notifications (optional; set LISTMONK_ENABLED=false to skip) +LISTMONK_ENABLED=false +LISTMONK_URL= +LISTMONK_USERNAME= +LISTMONK_PASSWORD= +LISTMONK_LIST_ID= +LISTMONK_INITIAL_EMAIL_REMINDER_ID= +LISTMONK_SECOND_EMAIL_REMINDER_ID= +LISTMONK_FINAL_EMAIL_REMINDER_ID= +SCHEJ_EMAIL_ADDRESS= +GMAIL_APP_PASSWORD= +MAILCHIMP_API_KEY= +MAILJET_API_KEY= +MAILJET_API_SECRET= +MAILJET_LIST_ID= + +# Slack / Discord (optional) +SLACK_DEV_WEBHOOK_URL= +SLACK_PROD_WEBHOOK_URL= +SLACK_MONETIZATION_WEBHOOK_URL= +DISCORD_BOT_TOKEN= +GUILD_ID= diff --git a/server/README.md b/server/README.md index a0808319..feaeb83d 100644 --- a/server/README.md +++ b/server/README.md @@ -1,15 +1,20 @@ # Schej.it API -API docs (available when the server is running): http://localhost:3002/swagger/index.html +Swagger (when running): http://localhost:3002/swagger/index.html -## Debug +## Quick start +- Prereqs: MongoDB, Go 1.20+ +- Env: copy `.env.example` to `.env` and fill required keys; optional integrations can stay blank for local dev +- Live reload: `go install github.com/cosmtrek/air@v1.43.0` +- Run: `air` (or `go run main.go`) +- Mongo URI: `mongodb://localhost:27017/schej-it` (or `mongodb://mongo:27017/schej-it` in docker-compose) -- Install mongodb -- Install `air`, a package that facilitates live reload for Go apps - - `go install github.com/cosmtrek/air@latest` -- To run the server, simply run `air` in the root directory of the server +## Seeding (optional) +- `cd scripts/seed_demo && MONGODB_URI=mongodb://localhost:27017 go run main.go` (creates demo user + event) -## Make a backup of the mongodb database +## Migrations +- Scripts in `scripts/*` are one-off data migrations. Only run them if you need that specific migration on existing data. -- Run `mongodump --host="localhost:27017" --db=schej-it` to make a backup -- Run `mongorestore --uri mongodb://localhost:27017 ./dump --drop` to restore +## Backups +- Backup: `mongodump --host="localhost:27017" --db=schej-it` +- Restore: `mongorestore --uri mongodb://localhost:27017 ./dump --drop` diff --git a/server/db/init.go b/server/db/init.go index 532705cb..ede0f536 100644 --- a/server/db/init.go +++ b/server/db/init.go @@ -2,6 +2,8 @@ package db import ( "context" + "os" + "strings" "time" "go.mongodb.org/mongo-driver/mongo" @@ -22,11 +24,36 @@ var FolderEventsCollection *mongo.Collection func Init() func() { // Establish mongodb connection - var ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + mongoURI := os.Getenv("MONGODB_URI") + if mongoURI == "" { + mongoURI = "mongodb://localhost:27017" + } + // Guard against malformed URIs (e.g., accidental extra slashes). If parsing fails, fall back to localhost. + if !strings.HasPrefix(mongoURI, "mongodb://") { + logger.StdErr.Printf("MONGODB_URI malformed (%s); falling back to mongodb://localhost:27017\n", mongoURI) + mongoURI = "mongodb://localhost:27017" + } + + var ( + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + err error + ) defer cancel() - Client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost")) + + // Try primary URI, then fall back to localhost if the host "mongo" is unreachable (common when running API outside compose). + connectURI := mongoURI + Client, err = mongo.Connect(ctx, options.Client().ApplyURI(connectURI)) if err != nil { - logger.StdErr.Panicln(err) + logger.StdErr.Printf("failed to connect to Mongo at %s: %v\n", connectURI, err) + // Always try a localhost fallback on any error (covers parse errors or host resolution failures) + fallback := "mongodb://localhost:27017" + if connectURI != fallback { + logger.StdErr.Printf("retrying Mongo connection with fallback %s\n", fallback) + Client, err = mongo.Connect(ctx, options.Client().ApplyURI(fallback)) + } + if err != nil { + logger.StdErr.Panicln(err) + } } // Define mongodb database + collections diff --git a/server/scripts/20230812_add_calendar_accounts/main.go b/server/scripts/20230812_add_calendar_accounts/main.go index d8418998..24def5de 100644 --- a/server/scripts/20230812_add_calendar_accounts/main.go +++ b/server/scripts/20230812_add_calendar_accounts/main.go @@ -8,6 +8,7 @@ import ( "go.mongodb.org/mongo-driver/bson/primitive" "schej.it/server/db" "schej.it/server/models" + "schej.it/server/utils" ) func main() { @@ -28,17 +29,21 @@ func main() { if user.CalendarAccounts == nil { user.CalendarAccounts = make(map[string]models.CalendarAccount) } - if _, ok := user.CalendarAccounts[user.Email]; !ok { - user.CalendarAccounts[user.Email] = models.CalendarAccount{ - Email: user.Email, - Picture: user.Picture, - Enabled: &[]bool{true}[0], // Workaround to pass a boolean pointer - AccessToken: user.AccessToken, - AccessTokenExpireDate: user.AccessTokenExpireDate, - RefreshToken: user.RefreshToken, + accountKey := utils.GetCalendarAccountKey(user.Email, models.GoogleCalendarType) + if _, ok := user.CalendarAccounts[accountKey]; !ok { + user.CalendarAccounts[accountKey] = models.CalendarAccount{ + CalendarType: models.GoogleCalendarType, + Email: user.Email, + Picture: user.Picture, + Enabled: utils.TruePtr(), + OAuth2CalendarAuth: &models.OAuth2CalendarAuth{ + AccessToken: user.AccessToken, + AccessTokenExpireDate: user.AccessTokenExpireDate, + RefreshToken: user.RefreshToken, + }, } - _, err := db.UsersCollection.UpdateByID(context.Background(), user.Id, bson.M{ + updates := bson.M{ "$set": bson.M{ "calendarAccounts": user.CalendarAccounts, }, @@ -47,8 +52,12 @@ func main() { "accessTokenExpireDate": "", "refreshToken": "", }, - }) - if err != nil { + } + if user.PrimaryAccountKey == nil { + updates["$set"].(bson.M)["primaryAccountKey"] = accountKey + } + + if _, err := db.UsersCollection.UpdateByID(context.Background(), user.Id, updates); err != nil { log.Fatal(err) } } @@ -69,6 +78,9 @@ type OldUser struct { // additional accounts the user wants to see google calendar events for CalendarAccounts map[string]models.CalendarAccount `json:"calendarAccounts" bson:"calendarAccounts,omitempty"` + // Primary account key (may be missing in old data) + PrimaryAccountKey *string `json:"primaryAccountKey" bson:"primaryAccountKey,omitempty"` + // Google OAuth stuff TokenOrigin models.TokenOriginType `json:"-" bson:"tokenOrigin,omitempty"` AccessToken string `json:"-" bson:"accessToken,omitempty"` diff --git a/server/scripts/seed_demo/main.go b/server/scripts/seed_demo/main.go new file mode 100644 index 00000000..7411fcfa --- /dev/null +++ b/server/scripts/seed_demo/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "schej.it/server/db" + "schej.it/server/models" + "schej.it/server/utils" +) + +/* +Seeds a demo user and event into the DB for local development. + +Usage: + + MONGODB_URI=mongodb://localhost:27017 go run main.go +*/ +func main() { + ctx := context.Background() + closeConn := db.Init() + defer closeConn() + + // Create/find demo user + email := "demo@timeful.local" + var user models.User + err := db.UsersCollection.FindOne(ctx, bson.M{"email": email}).Decode(&user) + if err == mongo.ErrNoDocuments { + accountKey := utils.GetCalendarAccountKey(email, models.GoogleCalendarType) + user = models.User{ + Id: primitive.NewObjectID(), + TimezoneOffset: 0, + Email: email, + FirstName: "Demo", + LastName: "User", + CalendarAccounts: map[string]models.CalendarAccount{ + accountKey: { + CalendarType: models.GoogleCalendarType, + Email: email, + Enabled: utils.TruePtr(), + }, + }, + PrimaryAccountKey: &accountKey, + IsPremium: utils.FalsePtr(), + NumEventsCreated: 0, + } + if _, err := db.UsersCollection.InsertOne(ctx, user); err != nil { + panic(err) + } + fmt.Println("Inserted demo user:", email) + } else if err != nil { + panic(err) + } else { + fmt.Println("Demo user already exists:", email) + } + + ownerId := user.Id + + // Create/find demo event + eventName := "Demo Availability" + existing := db.EventsCollection.FindOne(ctx, bson.M{"ownerId": ownerId, "name": eventName}) + if existing.Err() == nil { + fmt.Println("Demo event already exists:", eventName) + return + } + + // Build sample event with a few specific dates + now := time.Now().UTC() + dates := []primitive.DateTime{ + primitive.NewDateTimeFromTime(now.AddDate(0, 0, 1)), + primitive.NewDateTimeFromTime(now.AddDate(0, 0, 2)), + primitive.NewDateTimeFromTime(now.AddDate(0, 0, 3)), + } + + duration := float32(60) + timeIncrement := 30 + numResponses := 0 + + description := "Sample event seeded for local testing" + sendEmailAfter := 0 + + event := models.Event{ + Id: primitive.NewObjectID(), + OwnerId: ownerId, + Name: eventName, + Description: &description, + IsArchived: utils.FalsePtr(), + IsDeleted: utils.FalsePtr(), + Duration: &duration, + Dates: dates, + NotificationsEnabled: utils.FalsePtr(), + SendEmailAfterXResponses: &sendEmailAfter, + When2meetHref: nil, + CollectEmails: utils.FalsePtr(), + TimeIncrement: &timeIncrement, + HasSpecificTimes: utils.FalsePtr(), + Times: []primitive.DateTime{}, + Type: models.SPECIFIC_DATES, + CreatorPosthogId: nil, + IsSignUpForm: utils.FalsePtr(), + SignUpBlocks: &[]models.SignUpBlock{}, + SignUpResponses: map[string]*models.SignUpResponse{}, + StartOnMonday: utils.FalsePtr(), + BlindAvailabilityEnabled: utils.FalsePtr(), + DaysOnly: utils.FalsePtr(), + ResponsesMap: map[string]*models.Response{}, + NumResponses: &numResponses, + } + + shortId := db.GenerateShortEventId(event.Id) + event.ShortId = &shortId + + if _, err := db.EventsCollection.InsertOne(ctx, event); err != nil { + panic(err) + } + + fmt.Printf("Inserted demo event: %s (id: %s, shortId: %s)\n", event.Name, event.Id.Hex(), shortId) +} diff --git a/server/services/gcloud/tasks.go b/server/services/gcloud/tasks.go index 0f1bbc18..ee218f99 100644 --- a/server/services/gcloud/tasks.go +++ b/server/services/gcloud/tasks.go @@ -26,6 +26,15 @@ func InitTasks() func() { var err error credsFile := os.Getenv("SERVICE_ACCOUNT_KEY_PATH") + if credsFile == "" { + logger.StdOut.Println("SERVICE_ACCOUNT_KEY_PATH not set; skipping Cloud Tasks init") + return func() {} + } + + if _, statErr := os.Stat(credsFile); statErr != nil { + logger.StdOut.Printf("SERVICE_ACCOUNT_KEY_PATH not readable (%v); skipping Cloud Tasks init\n", statErr) + return func() {} + } TasksClient, err = cloudtasks.NewClient(ctx, option.WithCredentialsFile(credsFile)) if err != nil { @@ -39,6 +48,11 @@ func InitTasks() func() { } func CreateEmailTask(email string, ownerName string, eventName string, eventId string) []string { + if TasksClient == nil { + logger.StdOut.Println("Cloud Tasks client not initialized; skipping email task creation") + return []string{} + } + // Get listmonk url env vars listmonkUrl := os.Getenv("LISTMONK_URL") listmonkUsername := os.Getenv("LISTMONK_USERNAME") @@ -127,6 +141,11 @@ func CreateEmailTask(email string, ownerName string, eventName string, eventId s } func DeleteEmailTask(taskId string) { + if TasksClient == nil { + logger.StdOut.Println("Cloud Tasks client not initialized; skipping email task deletion") + return + } + err := TasksClient.DeleteTask(context.Background(), &cloudtaskspb.DeleteTaskRequest{ Name: taskId, })