diff --git a/.gitignore b/.gitignore index 0fcba83..04fc8c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ +.agents/ .venv site config.toml +*.log +*.pid +auth +api +tmp/ +dist/ +node_modules/ +.DS_Store +.antigravitycli diff --git a/.specify/.gitignore b/.specify/.gitignore new file mode 100644 index 0000000..e314fc2 --- /dev/null +++ b/.specify/.gitignore @@ -0,0 +1,9 @@ +# Machine-local Spec Kit state — not meant to be shared. +# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh). + +# Local pointer to the current feature directory. Rewritten every time you +# switch features, so it is per-checkout state rather than something to share. +feature.json + +# Per-machine extension config overrides. +extensions/*/local-config.yml diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 0000000..488a907 --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,9 @@ +{ + "ai": "agy", + "ai_skills": true, + "feature_numbering": "sequential", + "here": true, + "integration": "agy", + "script": "sh", + "speckit_version": "0.16.2.dev0" +} diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 0000000..fc56147 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,15 @@ +{ + "version": "0.16.2.dev0", + "integration_state_schema": 1, + "installed_integrations": [ + "agy" + ], + "integration_settings": { + "agy": { + "script": "sh", + "invoke_separator": "-" + } + }, + "integration": "agy", + "default_integration": "agy" +} diff --git a/.specify/integrations/agy.manifest.json b/.specify/integrations/agy.manifest.json new file mode 100644 index 0000000..acaf51b --- /dev/null +++ b/.specify/integrations/agy.manifest.json @@ -0,0 +1,17 @@ +{ + "integration": "agy", + "version": "0.16.2.dev0", + "installed_at": "2026-08-09T10:20:01.966773+00:00", + "files": { + ".agents/skills/speckit-analyze/SKILL.md": "2e89ffed73764eb9d3ee2d9fba7e245a5e73229b809ddec52409ca74c44e585d", + ".agents/skills/speckit-clarify/SKILL.md": "5e239a81711fb93b26a70f4384ee40461700d987245bda903c2966b4847d0e98", + ".agents/skills/speckit-constitution/SKILL.md": "b1870f61afe307e96d07b1cb7adb96ad8b3c637f86a79df165690b8696916a2a", + ".agents/skills/speckit-implement/SKILL.md": "248c2fa4be8a969ea771c88c38546d6735d7d4994bbe698bbabd18f53f88f0cd", + ".agents/skills/speckit-converge/SKILL.md": "1355b748919c7750cc87be94534192e5ff227229c14cbd34ca993a5f977a4ce2", + ".agents/skills/speckit-plan/SKILL.md": "c5522489fd177028e02ff4f1588a06bde811a0146cdc1d3495812694d6ea41b3", + ".agents/skills/speckit-checklist/SKILL.md": "4eb7e030d04014e6bbf5177f84aeef19b11adfa8470a14dd18506f65c7cf9dcb", + ".agents/skills/speckit-specify/SKILL.md": "fc1a68dceba9fa9317a9d7dabda4629995628dc6dcc7c80b313190141c1dee79", + ".agents/skills/speckit-tasks/SKILL.md": "2dceed4345af65dbc6625157be8817dc35f2b0943b5896815ef1bc4d3421efba", + ".agents/skills/speckit-taskstoissues/SKILL.md": "2c00fdc774cb9b435ea8566c7efa8b208568d533d57529e61c35f18aa52d6c67" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 0000000..0329578 --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,18 @@ +{ + "integration": "speckit", + "version": "0.16.2.dev0", + "installed_at": "2026-08-09T10:20:01.976252+00:00", + "files": { + ".specify/scripts/bash/check-prerequisites.sh": "a7d8a14ecf87332b600cd966b5d0e7cb9d594abce7e4d1ee4372b2b5b3efff06", + ".specify/scripts/bash/common.sh": "6ff86bf39f6b4684b0f80927dc7a1dadec26b4671988a3fe4d6c2523cbd3aa22", + ".specify/scripts/bash/create-new-feature.sh": "ad09a94a2c1107e25e5386a834da1d7a31f9abb06ab8bfd323a7b84038221e39", + ".specify/scripts/bash/setup-plan.sh": "4469b22960f43c07c33dca00de6dedb252145e9a9ce8fbb0e63be82e02b082ab", + ".specify/scripts/bash/setup-tasks.sh": "cf21ba2212b4dd5b435c5ea8527500cfd27768b86c0bbc7ebc3207759f118d27", + ".specify/templates/checklist-template.md": "709d8ab8384a3a49f5e0f64479f71553ef6d6f8bb4f00281b05f47837993b536", + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", + ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4", + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", + ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842", + ".specify/.gitignore": "8c908410d177a1ef3d0dee16d7ad55f2ac3333df3104c4d4adee1c9b82f1dbc1" + } +} diff --git a/.specify/memory/.constitution-template.json b/.specify/memory/.constitution-template.json new file mode 100644 index 0000000..2fe4dee --- /dev/null +++ b/.specify/memory/.constitution-template.json @@ -0,0 +1,4 @@ +{ + "sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", + "source": "core" +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000..7c25889 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,67 @@ + + +# FitFeed Constitution + +## Core Principles + +### I. Privacy-First & Data Ownership (NON-NEGOTIABLE) +FitFeed is a self-hosted, privacy-first fitness platform. Users MUST maintain total ownership and control of their fitness activities and health metrics data. All features MUST function in a self-hosted environment without mandatory dependencies on third-party cloud services. Telemetry, external tracking, or third-party data transmission is strictly prohibited unless explicitly configured and consented to by the user. + +### II. Microservice Modular Architecture & Clean Code +FitFeed functionality MUST be organized into decoupled microservices located within `services/` (`auth`, `api`, `dbm`, `web`). Each Go backend service MUST strictly adhere to Clean Architecture layering: +- `internal/entity`: Pure domain models and value objects with zero database or transport dependencies. +- `internal/usecase`: Application business logic and repository interfaces; MUST NOT import HTTP/AMQP routers or database drivers. +- `internal/repo`: Data persistence implementations using GORM Modern Generic API (`gorm.G[T](db)`). +- `internal/controller`: Inbound transport entry points (HTTP REST handlers and AMQP event/RPC consumers). + +Inter-service workflows spanning multiple bounded contexts MUST utilize an asynchronous SAGA pattern with compensation events (e.g. `user.creation_failed`) to guarantee eventual consistency and rollback on failure. Direct cross-service database access outside designated migrations or services is strictly forbidden. + +### III. Managed Database Schema Evolution & Generic ORM +All database schema updates MUST be implemented via version-controlled SQL migration scripts in `services/dbm` using Goose embedded directly into the Go binary (`embed.FS`). Direct manual database schema alterations or runtime auto-migrations in application code are strictly prohibited. Database queries in repositories MUST use GORM's modern Generic API (`gorm.G[T](db)`) for type safety. Schema definitions and queries MUST maintain dual-dialect compatibility (PostgreSQL 17 and MySQL). + +### IV. Contract-Driven & Type-Safe Interfaces +Backend APIs MUST enforce strict type checking in Go with clearly defined contract interfaces (`contracts.go`). Frontend client applications (`services/web`) MUST be written in TypeScript with full type safety and zero unhandled type coercions. Code compilation and static checks MUST pass cleanly before code integration. + +### V. Developer Environment Parity & Hot Reloading +Developer experience MUST be maintained through standard automation via the root `Makefile` and `Air` configuration. All core services MUST be launchable locally via `make dev`. Environment variables and local settings MUST be managed through template-driven configuration (`config.toml` derived from `config.toml.template`). + +## Security & Privacy Constraints + +FitFeed MUST uphold high security and data isolation standards: +- **Authentication**: User authentication MUST be handled securely by the `auth` service using Passkeys (WebAuthn passwordless authentication), OAuth 2.0 providers, and JWT distributed session tokens. +- **Data Protection**: Personal health measurements and activity locations MUST be protected against unauthorized access. GPS traces and sensitive user metrics MUST NOT be exposed on public endpoints without user authorization. +- **License Compliance**: FitFeed is licensed under AGPL-3.0. All code additions and third-party dependencies MUST comply with AGPL-3.0 licensing requirements. + +## Quality Assurance & Development Workflow + +Quality standards MUST be maintained across all services: +- **Git Workflow**: The project strictly follows Trunk-Based Development (TBD). Features and fixes MUST be developed on short-lived branches (`feat/*`, `fix/*`, `chore/*`) and merged into `main` frequently via validated Pull Requests with small, atomic commits. +- **Testing & Verification**: New endpoints and core domain logic MUST include unit or integration tests. Migrations MUST be verified against local database instances before merging (`make migrate-up` / `make migrate-down`). +- **Code Formatting**: Go code MUST strictly adhere to standard `gofmt` and `go vet` rules. Frontend code MUST adhere to ESLint/Prettier formatting standards. +- **Pull Request Approval**: PRs MUST demonstrate clean builds, passing tests, and compliance with all core principles prior to merge. + +## Governance + +- **Supremacy**: This constitution supersedes all informal team agreements, README guides, and project documentation. +- **Amendments**: Amendments to this constitution require a pull request specifying the rationale, version update, and Sync Impact Report. +- **Versioning Policy**: + - **MAJOR**: Backward-incompatible governance changes or redefinition/removal of core principles. + - **MINOR**: Addition of new principles, sections, or materially expanded governance rules. + - **PATCH**: Wording improvements, formatting, typo fixes, or non-semantic clarifications. +- **Compliance Review**: All feature design specifications, pull requests, and architecture plans MUST be validated against this constitution. + +**Version**: 1.1.0 | **Ratified**: 2025-07-18 | **Last Amended**: 2026-08-22 diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh new file mode 100755 index 0000000..bf75140 --- /dev/null +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +# Consolidated prerequisite checking script +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.sh [OPTIONS] +# +# OPTIONS: +# --json Output in JSON format +# --require-tasks Require tasks.md to exist (for implementation phase) +# --include-tasks Include tasks.md in AVAILABLE_DOCS list +# --paths-only Only output path variables (no validation) +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. + +set -e + +# Parse command line arguments +JSON_MODE=false +REQUIRE_TASKS=false +INCLUDE_TASKS=false +PATHS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --require-tasks) + REQUIRE_TASKS=true + ;; + --include-tasks) + INCLUDE_TASKS=true + ;; + --paths-only) + PATHS_ONLY=true + ;; + --help|-h) + cat << 'EOF' +Usage: check-prerequisites.sh [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check-prerequisites.sh --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check-prerequisites.sh --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check-prerequisites.sh --paths-only + +EOF + exit 0 + ;; + *) + echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths. +# In --paths-only mode this is pure resolution, so pass --no-persist to opt out +# of the feature.json write side effect (issue #3025). +if $PATHS_ONLY; then + _paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +else + _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +fi +eval "$_paths_output" +unset _paths_output + +# If paths-only mode, output paths and exit (no validation) +if $PATHS_ONLY; then + if $JSON_MODE; then + # Minimal JSON paths payload (no validation performed) + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi + else + echo "REPO_ROOT: $REPO_ROOT" + echo "BRANCH: $CURRENT_BRANCH" + echo "FEATURE_DIR: $FEATURE_DIR" + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "TASKS: $TASKS" + fi + exit 0 +fi + +# Validate required directories and files +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 + echo "Run /speckit-specify first to create the feature structure." >&2 + exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit-plan first to create the implementation plan." >&2 + exit 1 +fi + +# Check for tasks.md if required +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit-tasks first to create the task list." >&2 + exit 1 +fi + +# Build list of available documents +docs=() + +# Always check these optional docs +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + +# Check contracts directory (only if it exists and has files) +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi + +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Include tasks.md if requested and it exists +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then + docs+=("tasks.md") +fi + +# Output results +if $JSON_MODE; then + # Build JSON array of documents + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + fi +else + # Text output + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Show status of each potential document + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" + + if $INCLUDE_TASKS; then + check_file "$TASKS" "tasks.md" + fi +fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 0000000..dc60f9f --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,704 @@ +#!/usr/bin/env bash +# Common functions and variables for all scripts + +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +find_specify_root() { + local dir="${1:-$(pwd)}" + # Normalize to absolute path to prevent infinite loop with relative paths + # Use -- to handle paths starting with - (e.g., -P, -L) + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + # Stop if we've reached filesystem root or dirname stops changing + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} + +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit +# command against a member project from a monorepo root without cd. +# +# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute +# project root, or prints an error and returns 1. Strict by design: the path +# must exist and contain .specify/, with no silent fallback to cwd or the +# script-location default (which would silently write to the wrong project). +# +# This is the single resolver: bundled extensions inherit it by sourcing core +# (e.g. the git extension's create-new-feature-branch) rather than duplicating it. +resolve_specify_init_dir() { + local init_root + # Normalize: relative paths resolve against $(pwd); a trailing slash collapses. + # CDPATH="" so a relative value cannot be resolved against the caller's CDPATH + # (which would also echo to stdout and corrupt the captured path). + if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then + echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2 + return 1 + fi + if [[ ! -d "$init_root/.specify" ]]; then + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2 + return 1 + fi + printf '%s\n' "$init_root" +} + +# Get repository root, prioritizing .specify directory +# This prevents using a parent repository when spec-kit is initialized in a subdirectory +get_repo_root() { + # Explicit project override wins (see resolve_specify_init_dir). + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then + resolve_specify_init_dir + return + fi + + # First, look for .specify directory (spec-kit's own marker) + local specify_root + if specify_root=$(find_specify_root); then + echo "$specify_root" + return + fi + + # Final fallback to script location + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) +} + +# Get current feature name from explicit state only. +# Returns the feature identifier or empty string if none is set. +# Feature state is set by SPECIFY_FEATURE (from create-new-feature or +# the git extension) or implicitly via .specify/feature.json. +get_current_branch() { + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + echo "$SPECIFY_FEATURE" + return + fi + + # No explicit feature set — caller must handle this via feature.json + # in get_feature_paths(). Return empty to signal "unknown". + echo "" +} + +# Safely read .specify/feature.json's "feature_directory" value. +# Prints the raw value (possibly relative) to stdout, or empty string if the file +# is missing, unparseable, or does not contain the key. Always returns 0 so callers +# under `set -e` cannot be aborted by parser failure. +# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. +read_feature_json_feature_directory() { + local repo_root="$1" + local fj="$repo_root/.specify/feature.json" + [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + + # Try parsers in order (jq -> python3 -> grep/sed), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App Execution + # Alias stub, which passes `command -v` but fails at runtime (exit 49), so + # an availability-gated `elif` would pick python3, swallow its failure, and + # never reach the grep/sed fallback -- leaving feature.json unreadable even + # though it is valid (issue #3304). + local _fd='' + if command -v jq >/dev/null 2>&1; then + if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then + _fd='' + fi + fi + if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then + # Use Python so pretty-printed/multi-line JSON still parses correctly. + if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then + _fd='' + fi + fi + if [[ -z "$_fd" ]]; then + # Last-resort single-line grep/sed fallback. The `|| true` guards against + # grep returning 1 (no match) aborting under `set -e` / `pipefail`. + _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ + | head -n 1 \ + | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) + fi + + printf '%s' "$_fd" + return 0 +} + +# Persist a feature_directory value to .specify/feature.json. +# Writes only when the file is missing or the value differs from what's stored. +# Accepts the raw (possibly relative) path — callers should pass the original +# user-supplied value, not the normalized absolute path. +_persist_feature_json() { + local repo_root="$1" + local feature_dir_value="$2" + local fj="$repo_root/.specify/feature.json" + + # Strip repo_root prefix if the value is absolute and under repo_root + if [[ "$feature_dir_value" == "$repo_root/"* ]]; then + feature_dir_value="${feature_dir_value#"$repo_root/"}" + fi + + # Read current value (if any) and skip write when unchanged + local current_val + current_val=$(read_feature_json_feature_directory "$repo_root") + if [[ "$current_val" == "$feature_dir_value" ]]; then + return 0 + fi + + # Ensure .specify/ directory exists + mkdir -p "$repo_root/.specify" + + # Write feature.json — prefer jq for safe JSON, fall back to printf + if command -v jq >/dev/null 2>&1; then + jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj" + else + printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj" + fi +} + +get_feature_paths() { + # Read-only callers (e.g. check-prerequisites.sh --paths-only) pass + # --no-persist so pure path resolution never writes .specify/feature.json, + # which would dirty the working tree or overwrite a pinned value (issue #3025). + local no_persist=false + if [[ "${1:-}" == "--no-persist" ]]; then + no_persist=true + shift + fi + + # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in + # get_repo_root propagates as a hard error instead of being masked by `local`. + local repo_root + repo_root=$(get_repo_root) || return 1 + local current_branch + current_branch=$(get_current_branch) + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by specify command) + # 3. Error — no feature context available + local feature_dir + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + feature_dir="$SPECIFY_FEATURE_DIRECTORY" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + # Persist to feature.json so future sessions without the env var still + # work — unless the caller opted out for read-only resolution (#3025). + if [[ "$no_persist" != true ]]; then + _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" + fi + elif [[ -f "$repo_root/.specify/feature.json" ]]; then + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + else + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2 + return 1 + fi + else + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2 + return 1 + fi + + # When no branch context exists (no SPECIFY_FEATURE, feature resolved via + # SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature + # directory basename so CURRENT_BRANCH is a usable identifier rather than + # an empty, misleading value (issue #3026). + if [[ -z "$current_branch" ]]; then + local feature_dir_trimmed="${feature_dir%/}" + current_branch="${feature_dir_trimmed##*/}" + fi + + # Use printf '%q' to safely quote values, preventing shell injection + # via crafted branch names or paths containing special characters + printf 'REPO_ROOT=%q\n' "$repo_root" + printf 'CURRENT_BRANCH=%q\n' "$current_branch" + printf 'FEATURE_DIR=%q\n' "$feature_dir" + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" + printf 'TASKS=%q\n' "$feature_dir/tasks.md" + printf 'RESEARCH=%q\n' "$feature_dir/research.md" + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" +} + +# Check if jq is available for safe JSON construction +has_jq() { + command -v jq >/dev/null 2>&1 +} + +get_invoke_separator() { + local repo_root="${1:-$(get_repo_root)}" + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then + printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" + return 0 + fi + + local integration_json="$repo_root/.specify/integration.json" + local separator="." + local parsed=0 + + if [[ -f "$integration_json" ]]; then + # Try parsers in order (jq -> python3 -> awk), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App + # Execution Alias stub, which passes `command -v` but fails at runtime + # (exit 49). An availability-gated branch would pick python3, swallow + # its failure, and — because this function historically had no text + # fallback — silently return "." even for `-`-separator integrations + # (e.g. forge, cline), yielding wrong command hints (issue #3304). + if command -v jq >/dev/null 2>&1; then + local jq_separator + if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then + case "$jq_separator" in + "."|"-") separator="$jq_separator"; parsed=1 ;; + esac + fi + fi + + if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then + local py_separator + if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as fh: + state = json.load(fh) + key = state.get("default_integration") or state.get("integration") or "" + settings = state.get("integration_settings") + separator = "." + if isinstance(key, str) and isinstance(settings, dict): + entry = settings.get(key) + if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}: + separator = entry["invoke_separator"] + print(separator) +except Exception: + sys.exit(1) +PY +); then + case "$py_separator" in + "."|"-") separator="$py_separator"; parsed=1 ;; + esac + fi + fi + + if [[ "$parsed" -eq 0 ]]; then + # Last-resort text fallback for environments with neither jq nor a + # working python3 (e.g. stock Windows + Git Bash). Reads the active + # integration key (default_integration, else integration) and its + # invoke_separator from within the integration_settings object. + # Handles both pretty-printed (the written form) and compact JSON. + # Accumulate all lines into one buffer in END rather than using + # gawk-only whole-file slurp (RS="^$"), so this stays portable to + # the BSD awk on macOS. + local awk_separator + awk_separator=$(awk ' + function keyval(d, name, v) { + if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) { + v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v + } + return "" + } + { doc = doc $0 "\n" } + END { + key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration") + sep="." + if (key!="") { + settings=doc + if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) { + settings=substr(doc, RSTART+RLENGTH-1) + } + if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) { + start=RSTART+RLENGTH-1 + depth=0 + obj="" + for (i=start; i<=length(settings); i++) { + c=substr(settings,i,1) + obj=obj c + if (c=="{") depth++ + else if (c=="}") { depth--; if (depth==0) break } + } + if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) { + tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1) + if (s=="." || s=="-") sep=s + } + } + } + print sep + } + ' "$integration_json" 2>/dev/null) + case "$awk_separator" in + "."|"-") separator="$awk_separator" ;; + esac + fi + fi + + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" + printf '%s\n' "$separator" +} + +format_speckit_command() { + local command_name="$1" + local repo_root="${2:-$(get_repo_root)}" + local separator + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then + separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" + else + separator=$(get_invoke_separator "$repo_root") + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" + fi + + command_name="${command_name#/}" + command_name="${command_name#speckit.}" + command_name="${command_name#speckit-}" + command_name="${command_name//./$separator}" + + printf '/speckit%s%s\n' "$separator" "$command_name" +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done +} + +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi + # python3 succeeded but registry has no presets — nothing to search + else + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 +} + +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +# +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") +# Returns composed content string on stdout; exit code 1 if not found. +resolve_template_content() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Collect all layers (highest priority first) + local -a layer_paths=() + local -a layer_strategies=() + + # Priority 1: Project overrides (always "replace") + local override="$base/overrides/${template_name}.md" + if [ -f "$override" ]; then + layer_paths+=("$override") + layer_strategies+=("replace") + fi + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + local sorted_presets="" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + local yaml_warned=false + while IFS= read -r preset_id; do + # Read strategy and file path from preset manifest + local strategy="replace" + local manifest_file="" + local manifest="$presets_dir/$preset_id/preset.yml" + if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then + # Requires PyYAML; falls back to replace/convention if unavailable + local result + local py_stderr + py_stderr=$(mktemp) + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " +import sys, os +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(os.environ['SPECKIT_MANIFEST']) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +" 2>"$py_stderr") + local parse_status=$? + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then + IFS=$'\t' read -r strategy manifest_file <<< "$result" + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then + echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 + yaml_warned=true + fi + rm -f "$py_stderr" + fi + # Try manifest file path first, then convention path + local candidate="" + if [ -n "$manifest_file" ]; then + # Reject absolute paths and parent traversal + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + if [ -n "$manifest_file" ]; then + local mf="$presets_dir/$preset_id/$manifest_file" + [ -f "$mf" ] && candidate="$mf" + fi + if [ -z "$candidate" ]; then + local cf="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" + fi + if [ -n "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("$strategy") + fi + done <<< "$sorted_presets" + fi + else + # python3 failed — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + else + # No python3 or registry — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + fi + + # Priority 3: Extension-provided templates (always "replace") + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + + # Priority 4: Core templates (always "replace") + local core="$base/${template_name}.md" + if [ -f "$core" ]; then + layer_paths+=("$core") + layer_strategies+=("replace") + fi + + local count=${#layer_paths[@]} + [ "$count" -eq 0 ] && return 1 + + # Check if any layer uses a non-replace strategy + local has_composition=false + for s in "${layer_strategies[@]}"; do + [ "$s" != "replace" ] && has_composition=true && break + done + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if [ "${layer_strategies[0]}" = "replace" ]; then + cat "${layer_paths[0]}" + return 0 + fi + + if [ "$has_composition" = false ]; then + cat "${layer_paths[0]}" + return 0 + fi + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + local base_idx=-1 + local i + for (( i=0; i=0; i-- )); do + local path="${layer_paths[$i]}" + local strat="${layer_strategies[$i]}" + local layer_content + # Preserve trailing newlines + layer_content=$(cat "$path"; printf x) + layer_content="${layer_content%x}" + + case "$strat" in + replace) content="$layer_content" ;; + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; + wrap) + case "$layer_content" in + *'{CORE_TEMPLATE}'*) ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + esac + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do + local before="${layer_content%%\{CORE_TEMPLATE\}*}" + local after="${layer_content#*\{CORE_TEMPLATE\}}" + layer_content="${before}${content}${after}" + done + content="$layer_content" + ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + esac + done + + printf '%s' "$content" + return 0 +} diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000..c1b189d --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,392 @@ +#!/usr/bin/env bash + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +NUMBER_EXPLICIT=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + if [ -n "$BRANCH_NUMBER" ]; then + NUMBER_EXPLICIT=true + fi + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute feature name and paths without creating directories or files" + echo " --allow-existing-branch Reuse an existing feature directory if it already exists" + echo " --short-name Provide a custom short name (2-4 words) for the feature" + echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +MAX_FEATURE_NUMBER=9223372036854775807 +MAX_BRANCH_LENGTH=244 + +is_feature_number_in_range() { + local value="$1" + local normalized="${value#"${value%%[!0]*}"}" + [ -n "$normalized" ] || normalized=0 + [ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0 + [ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1 + # Equal-length digit strings must be compared without arithmetic overflow. + # shellcheck disable=SC2071 + [[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]] +} + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + if is_feature_number_in_range "$number"; then + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + fi + done + fi + + echo "$highest" +} + +# Return success when a spec directory owns the given numeric prefix. +spec_prefix_exists() { + local specs_dir="$1" + local feature_num="$2" + + for spec_path in "$specs_dir/${feature_num}-"*; do + [ -d "$spec_path" ] && return 0 + done + return 1 +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# Fit a feature prefix and suffix within GitHub's branch-name limit. +fit_branch_name() { + local feature_num="$1" + local branch_suffix="$2" + local branch_name="${feature_num}-${branch_suffix}" + + if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then + local prefix_length=$(( ${#feature_num} + 1 )) + local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length)) + local truncated_suffix + truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//') + branch_name="${feature_num}-${truncated_suffix}" + fi + + printf '%s' "$branch_name" +} + +# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote +# so the persistence hints match the Python variant exactly (printf %q output +# differs between bash versions and from shlex.quote for spaces/metachars). +shell_quote() { + local value="$1" LC_ALL=C + if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then + printf '%s' "$value" + else + local q="'\"'\"'" + printf "'%s'" "${value//\'/$q}" + fi +} + +# Resolve repository root using common.sh functions which prioritize .specify +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +REPO_ROOT=$(get_repo_root) || exit 1 + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +if [ "$DRY_RUN" != true ]; then + mkdir -p "$SPECS_DIR" +fi + +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + # Convert to lowercase and split into words + local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + # Keep short words that appear as an uppercase acronym in the original. + # Uppercase via tr and match with grep -w (both portable) rather than + # bash's 4+ "^^" case expansion (breaks on macOS bash 3.2) and \b (non-POSIX). + elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" +fi + +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2 + exit 1 + fi + + # Bash arithmetic is signed 64-bit; reject digit strings that would wrap. + if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then + echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2 + exit 1 + fi + + # Determine branch number from existing feature directories + if [ -z "$BRANCH_NUMBER" ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 + exit 1 + fi + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + + # Treat an explicit number as a preference when its prefix is already used + # by a feature directory. Auto-detected numbers are already conflict-free. + if [ "$NUMBER_EXPLICIT" = true ]; then + SPEC_CONFLICT=false + REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") + REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME" + if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then + spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true + fi + + if [ "$SPEC_CONFLICT" = true ]; then + REQUESTED_NUM="$FEATURE_NUM" + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$HIGHEST + while true; do + if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 + exit 1 + fi + BRANCH_NUMBER=$((BRANCH_NUMBER + 1)) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break + done + >&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead" + fi + fi + +fi + +# GitHub enforces a 244-byte limit on branch names +# Validate and truncate if necessary +ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") +if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then + if [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name." + else + >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number." + fi + exit 1 + fi + + mkdir -p "$FEATURE_DIR" + + if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi + fi + + # Persist to .specify/feature.json so downstream commands can find the feature + _persist_feature_json "$REPO_ROOT" "$FEATURE_DIR" + + # Inform the user how to set feature state in their own shell + printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2 + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + fi + else + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" + fi +fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh new file mode 100755 index 0000000..e01dc44 --- /dev/null +++ b/.specify/scripts/bash/setup-plan.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false +ARGS=() + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac +done + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Ensure the feature directory exists +mkdir -p "$FEATURE_DIR" + +# Copy plan template if plan doesn't already exist +if [[ -f "$IMPL_PLAN" ]]; then + if $JSON_MODE; then + echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 + else + echo "Plan already exists at $IMPL_PLAN, skipping template copy" + fi +else + TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true + if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + if $JSON_MODE; then + echo "Copied plan template to $IMPL_PLAN" >&2 + else + echo "Copied plan template to $IMPL_PLAN" + fi + else + if $JSON_MODE; then + echo "Warning: Plan template not found" >&2 + else + echo "Warning: Plan template not found" + fi + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" + fi +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" + fi +else + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "SPECS_DIR: $FEATURE_DIR" + echo "BRANCH: $CURRENT_BRANCH" +fi diff --git a/.specify/scripts/bash/setup-tasks.sh b/.specify/scripts/bash/setup-tasks.sh new file mode 100755 index 0000000..ae0d7bd --- /dev/null +++ b/.specify/scripts/bash/setup-tasks.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false + +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Validate required files +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit-plan first to create the implementation plan." >&2 + exit 1 +fi + +if [[ ! -f "$FEATURE_SPEC" ]]; then + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit-specify first to create the feature structure." >&2 + exit 1 +fi + +# Build available docs list +docs=() +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Resolve tasks template through override stack +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true +if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 + echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 + exit 1 +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + --arg tasks_template "${TASKS_TEMPLATE:-}" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" + fi +else + echo "FEATURE_DIR: $FEATURE_DIR" + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" + echo "AVAILABLE_DOCS:" + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" +fi diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md new file mode 100644 index 0000000..e64065d --- /dev/null +++ b/.specify/templates/checklist-template.md @@ -0,0 +1,40 @@ +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] + +**Purpose**: [Brief description of what this checklist covers] +**Created**: [DATE] +**Feature**: [Link to spec.md or relevant documentation] + +**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements. + + + +## [Category 1] + +- [ ] CHK001 First checklist item with clear action +- [ ] CHK002 Second checklist item +- [ ] CHK003 Third checklist item + +## [Category 2] + +- [ ] CHK004 Another category item +- [ ] CHK005 Item with specific criteria +- [ ] CHK006 Final item in this category + +## Notes + +- Check items off as completed: `[x]` +- Add comments or findings inline +- Link to relevant resources or documentation +- Items are numbered sequentially for easy reference diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md new file mode 100644 index 0000000..a4670ff --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md new file mode 100644 index 0000000..be1aa88 --- /dev/null +++ b/.specify/templates/plan-template.md @@ -0,0 +1,113 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] + +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] + +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] + +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] + +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] + +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] + +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] + +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] + +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] + +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output (/speckit-plan command) +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + + +```text +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real +directories captured above] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md new file mode 100644 index 0000000..ceb2877 --- /dev/null +++ b/.specify/templates/spec-template.md @@ -0,0 +1,131 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` + +**Created**: [DATE] + +**Status**: Draft + +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + + +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md new file mode 100644 index 0000000..d46a1f1 --- /dev/null +++ b/.specify/templates/tasks-template.md @@ -0,0 +1,252 @@ +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Setup database schema and migrations framework +- [ ] T005 [P] Implement authentication/authorization framework +- [ ] T006 [P] Setup API routing and middleware structure +- [ ] T007 Create base models/entities that all stories depend on +- [ ] T008 Configure error handling and logging infrastructure +- [ ] T009 Setup environment configuration management + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T016 [US1] Add validation and error handling +- [ ] T017 [US1] Add logging for user story 1 operations + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 2 + +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py +- [ ] T021 [US2] Implement [Service] in src/services/[service].py +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 3 + +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py +- [ ] T027 [US3] Implement [Service] in src/services/[service].py +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in docs/ +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance optimization across all stories +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ +- [ ] TXXX Security hardening +- [ ] TXXX Run quickstart.md validation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable + +### Within Each User Story + +- Tests (if included) MUST be written and FAIL before implementation +- Models before services +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All tests for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together (if tests requested): +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" +Task: "Integration test for [user journey] in tests/integration/test_[name].py" + +# Launch all models for User Story 1 together: +Task: "Create [Entity1] model in src/models/[entity1].py" +Task: "Create [Entity2] model in src/models/[entity2].py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/.specify/workflows/speckit/workflow.yml b/.specify/workflows/speckit/workflow.yml new file mode 100644 index 0000000..230675b --- /dev/null +++ b/.specify/workflows/speckit/workflow.yml @@ -0,0 +1,78 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + # 0.8.5 is the first release with engine-side resolution of the + # ``integration: "auto"`` default. Older versions would treat "auto" + # as a literal integration key and fail at dispatch. + speckit_version: ">=0.8.5" + integrations: + # The four commands below (specify, plan, tasks, implement) are core + # spec-kit commands provided by every integration. The list here is an + # advisory, non-exhaustive compatibility hint following the documented + # ``any: [...]`` schema -- it is NOT a closed set. The workflow runs + # against any integration the project was initialized with, including + # ones not listed below, as long as that integration provides the four + # core commands referenced in ``steps``. + any: + - "alquimia" + - "claude" + - "copilot" + - "gemini" + - "opencode" + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" diff --git a/.specify/workflows/workflow-registry.json b/.specify/workflows/workflow-registry.json new file mode 100644 index 0000000..7195e96 --- /dev/null +++ b/.specify/workflows/workflow-registry.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "workflows": { + "speckit": { + "name": "Full SDD Cycle", + "version": "1.0.0", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "source": "bundled", + "installed_at": "2026-08-09T10:20:01.992155+00:00", + "updated_at": "2026-08-09T10:20:01.992170+00:00" + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a6afbd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,285 @@ +# AGENTS.md — FitFeed Developer & AI Agent Guide + +Welcome to **FitFeed**, a privacy-first, self-hosted fitness social platform (an open-source analogue to platforms like Strava). + +This document is the authoritative guide for AI agents and developers contributing to this codebase. It establishes core architectural patterns, coding conventions, technology stacks, workflows, and rules of engagement. + +--- + +## 1. Project Overview & Philosophy + +- **Domain:** Privacy-first fitness social network for activity tracking, social interaction (likes, comments), wearable sync (Garmin, Polar, Suunto), and health metrics. +- **Hosting & Repository:** Hosted on [GitHub](https://github.com/Linux-oiD/fitfeed). +- **Core Values:** + - **Privacy by Design:** User data is strictly controlled, self-hosted, and isolated. + - **Decoupled Microservices:** Services maintain single responsibilities and bounded contexts. + - **Event-Driven Resilience:** Inter-service workflows leverage event sourcing / SAGA patterns with compensation. + - **Type Safety & Maintainability:** Strict Go and TypeScript typing throughout. + +--- + +## 2. Technology Stack Summary + +| Domain | Technology | Details | +| :--- | :--- | :--- | +| **Backend Language** | Go (Golang) `1.23+` | High-performance compiled microservices. | +| **Frontend Framework** | React 18+ & TypeScript | Fast, type-safe UI with Vite bundler & Bun runtime. | +| **UI Component Library**| Ant Design (`antd`) | Consistent design system, dark mode support, comprehensive UI components. | +| **Databases** | PostgreSQL 17 / MySQL | Relational data persistence with dialect-agnostic schemas. | +| **ORM** | GORM (Modern Generic API) | Using `gorm.G[T](db)` for type-safe database queries. | +| **DB Migrations** | Goose (`goose/v3`) | Raw SQL migrations compiled/embedded directly into Go binary (`embed.FS`). | +| **Message Broker / RPC**| RabbitMQ (AMQP 0.9.1) | Event bus and RPC messaging for inter-service communication. | +| **Hot Reload & Dev** | Air (`.air.*.toml`), Make, Docker Compose | Unified local multi-service orchestration. | +| **Git Workflow** | Trunk-Based Development | Short-lived branches, frequent merges to `main`, CI validation. | + +--- + +## 3. Directory Structure + +```text +fitfeed/ +├── .agents/ # Agent skills and workflow configurations +├── deployments/ # Deployment configs (Docker Compose, PgAdmin, DB scripts) +│ └── docker-compose/ +│ └── postgres/ # Local PostgreSQL, PgAdmin, and RabbitMQ setup +├── docs/ # MkDocs documentation source and developer guides +│ └── src/ +│ ├── admin-guides/ # Configuration & deployment guides +│ └── developer-guides/# Architecture, data models, service overviews +├── services/ # Microservice codebase +│ ├── api/ # Core API service (profiles, activities, social feeds) +│ │ ├── cmd/api/ # Application entry point +│ │ ├── config/ # Service config defaults +│ │ ├── internal/ # Clean Architecture layers (entity, usecase, repo, controller, bus) +│ │ └── pkg/ # Shared packages (AMQP, HTTP helpers, logger) +│ ├── auth/ # Authentication service (OAuth, WebAuthn/Passkeys, JWT) +│ │ ├── cmd/auth/ # Application entry point +│ │ ├── config/ # Service config defaults +│ │ ├── internal/ # Clean Architecture layers (entity, usecase, repo, controller, bus) +│ │ └── pkg/ # Shared packages +│ ├── dbm/ # Database Migration Manager CLI (Goose + GORM) +│ │ ├── cmd/dbm/ # Migration entry point (`up`, `down`, `status`, `create`) +│ │ └── internal/ +│ │ └── migrations/ # Embedded SQL migration files (*.sql + embed.go) +│ └── web/ # Frontend client application (React + AntD + Vite) +│ ├── src/ +│ │ ├── components/ # Reusable UI components & layouts +│ │ ├── context/ # Global state (AuthContext) +│ │ ├── pages/ # Route views (Home, Profile, Settings) +│ │ └── services/ # Typed API client services +│ └── package.json +├── config.toml.template # Root configuration template +├── Makefile # Top-level build and orchestration targets +├── README.md # Project overview +└── AGENTS.md # This operational guide for agents and engineers +``` + +--- + +## 4. Backend Architecture: Clean Architecture + +Each Go backend service (`services/api`, `services/auth`, etc.) **must** strictly adhere to the layered **Clean Architecture**: + +```mermaid +graph TD + Controller["Controllers (HTTP Handlers / AMQP Consumers / RPC)"] --> UseCase["Use Cases (Business Logic / Orchestrators)"] + UseCase --> RepoContract["Repository & Gateway Interfaces"] + UseCase --> Entity["Entities & Domain Models"] + RepoImpl["Repository Implementations (GORM generic)"] -.->|implements| RepoContract + RepoImpl --> Entity +``` + +### Dependency Rules: +1. **`internal/entity`**: + - Contains pure domain entities, structs, value objects, and domain errors. + - **Zero external dependencies** (no DB drivers, no web framework imports, no GORM specific tags except standard JSON/GORM base definitions if shared). +2. **`internal/usecase`**: + - Contains application-specific business logic and interfaces (`contracts.go`). + - Defines interfaces for repositories (`repo.UserDB`, etc.) and event dispatchers. + - Must never directly import HTTP routers, AMQP handlers, or database drivers. +3. **`internal/repo`**: + - Implements repository interfaces defined in `usecase` or `repo/contracts.go`. + - Uses **GORM Modern Generic API** (`gorm.G[Entity](db)`). +4. **`internal/controller`**: + - Inbound entrypoints: + - `internal/controller/http`: HTTP handlers, REST routers, request validation, middleware. + - `internal/controller/amqp`: RabbitMQ event consumers, AMQP RPC servers. +5. **`internal/bus`**: + - Event and message schemas for inter-service communication over AMQP. + +--- + +## 5. Modern Database & GORM Conventions + +### 5.1 GORM Modern Generic Approach +All database queries in repositories **must** use GORM's generic API (`gorm.G[T](db)`) rather than untyped reflection-based calls. + +```go +// ✅ CORRECT: Type-safe GORM generic approach +func (u *UserDB) GetByID(ctx context.Context, id uuid.UUID) (entity.User, error) { + user, err := gorm.G[entity.User](u.db). + Preload("Profile", func(db gorm.PreloadBuilder) error { return nil }). + Where("id = ?", id). + First(ctx) + return user, err +} + +func (u *UserDB) Create(ctx context.Context, user *entity.User) error { + return gorm.G[entity.User](u.db).Create(ctx, user) +} + +func (u *UserDB) List(ctx context.Context, limit, offset int) ([]entity.User, error) { + return gorm.G[entity.User](u.db). + Limit(limit). + Offset(offset). + Find(ctx) +} +``` + +- **Avoid** legacy/untyped idioms like `db.WithContext(ctx).Where("id = ?", id).First(&user)` when `gorm.G[T]` is applicable. +- Handle soft deletes using the `entity.Base` struct (`DeletedAt gorm.DeletedAt`). + +### 5.2 Embedded Goose SQL Migrations +- Schema evolution is managed via raw SQL in `services/dbm/internal/migrations/`. +- All `.sql` files are embedded into the Go binary via `//go:embed *.sql` in `embed.go`. +- Migrations follow Goose format: + ```sql + -- +goose Up + -- SQL in section 'Up' is executed when this migration is applied + CREATE TABLE IF NOT EXISTS activities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE + ); + + -- +goose Down + -- SQL section 'Down' is executed when this migration is rolled back + DROP TABLE IF EXISTS activities; + ``` +- **Dual Dialect Support:** Keep SQL dialect-agnostic where possible so both PostgreSQL and MySQL can be supported. + +--- + +## 6. Inter-Service Communication & SAGA Pattern + +```mermaid +sequenceDiagram + autonumber + actor User + participant Web as Web Frontend (React) + participant Auth as Auth Service + participant Bus as RabbitMQ (AMQP) + participant API as API Service + participant DB as Postgres / MySQL + + User->>Web: Register (OAuth / Passkey) + Web->>Auth: POST /auth/register + Auth->>DB: Save Auth Provider / Passkey Credential + Auth->>Bus: Publish Event: `user.created` + Auth-->>Web: Return Registration Session / Token + + Bus->>API: Consume `user.created` + alt API User Profile Creation Succeeds + API->>DB: Create User & Profile records + else API User Profile Creation Fails + API->>Bus: Publish Compensation: `user.creation_failed` + Bus->>Auth: Consume `user.creation_failed` + Auth->>DB: Compensate (Rollback / Invalidate Auth Record) + end +``` + +### 6.1 Event Publishing & SAGA Guidelines +1. **Asynchronous SAGA (Choreography):** + - Initiating service performs local transaction and emits an event (e.g. `user.created`). + - Downstream services listen to the event and execute local transactions. + - If downstream fails, it publishes a failure compensation event (e.g. `user.creation_failed`). + - The originating service consumes compensation events and executes rollback logic. +2. **Synchronous RPC over AMQP:** + - Used for read-only synchronous validations between services (e.g., `user.lookup` during auth flow). + - Use timeout-bounded correlation IDs (`pkg/amqp.Client.CallRPC`). +3. **Idempotency:** + - All AMQP consumers must be idempotent. Repeating a message must not corrupt state. + +--- + +## 7. Frontend Guidelines (React + TypeScript + Ant Design) + +- **UI Framework:** Ant Design 5+ (`antd`) components for tables, modals, forms, notifications, and navigation. +- **State & Auth:** Use `AuthContext` (`services/web/src/context/AuthContext.tsx`) for global user state, tokens, and login/logout handlers. +- **API Services:** Keep HTTP calls isolated in `services/web/src/services/` (e.g., `auth.ts`, `profile.ts`), with strict TypeScript interfaces for request/response bodies. +- **Styling:** Ant Design ConfigProvider / themes + CSS modules or standard clean CSS. Follow responsive design. +- **Build Tool:** Vite with Bun runtime (`bun install`, `bun run dev`, `bun run build`). + +--- + +## 8. Git & Development Workflow: Trunk-Based Development + +The project strictly follows **Trunk-Based Development (TBD)**: + +1. **Trunk (`main` branch):** + - The core single source of truth. Always kept deployable and green. +2. **Short-Lived Feature Branches:** + - Branch names: `feat/`, `fix/`, `chore/`. + - Lifespan should be short (ideally < 1-2 days). + - Avoid long-lived diverges or merge conflicts. +3. **Small, Atomic Commits:** + - Use clear conventional commit messages: `feat(api): add comment endpoints`, `fix(auth): handle passkey signature error`. +4. **Pull Requests & Code Reviews:** + - Merge into `main` via PRs with automated CI checks passing. + - Run tests and linting before submitting PRs. + +--- + +## 9. Local Development & Operational Commands + +The project includes a root `Makefile` that orchestrates development: + +```bash +# Check dependencies and initialize config.toml +make init + +# Start local dependencies (Postgres, RabbitMQ, PgAdmin) via Docker +make dev-db + +# Run embedded DB migrations up +make migrate-up + +# Run embedded DB migrations down +make migrate-down + +# Start full dev environment with hot-reload (DB + Auth + API + Web) +make dev + +# Stop dev containers +make dev-stop + +# Individual service dev commands +make dev-auth # Air hot-reload for Auth service +make dev-api # Air hot-reload for API service +make dev-web # Bun + Vite for Frontend +``` + +--- + +## 10. Rules for AI Agents + +When implementing features or fixing bugs in this repository, AI agents **MUST**: + +1. **Follow Layer Boundaries:** + - Never import GORM / DB drivers inside `internal/entity` or `internal/usecase`. + - Keep business logic in `usecase`, transport logic in `controller`, persistence in `repo`. +2. **Use GORM Generic API:** + - Always write database queries using `gorm.G[Model](db)` methods. +3. **Database Schema Changes:** + - Never alter tables dynamically in Go application startup code. + - Always create a new versioned `.sql` file in `services/dbm/internal/migrations/` with valid `-- +goose Up` and `-- +goose Down` blocks. +4. **Inter-Service Communication:** + - For multi-service side effects, register event payloads in `internal/bus` and handle compensation events. +5. **Configuration Consistency:** + - Use `config.toml` and ensure new configuration keys are updated in `config.toml.template`. +6. **Preserve Documentation & Code Quality:** + - Preserve existing comments and docstrings. + - Structure Go code with `log/slog` logging and clean error propagation. diff --git a/config.toml.template b/config.toml.template index e5ee05c..c26b975 100644 --- a/config.toml.template +++ b/config.toml.template @@ -1,5 +1,3 @@ -is_prod = false - [api] port = 8082 @@ -8,6 +6,7 @@ port = 8081 prefix = "auth" secret = "MuUhv7svOw9iWmeycg7iRhsuF5hr4Gik" max_session_age = 86400 +is_prod = false [database] driver = "postgres" @@ -19,6 +18,9 @@ username = "postgres" password = "secret" dbname = "fitfeed" +[rabbitmq] +url = "amqp://guest:guest@localhost:5672/" + [web] hostname = "localhost" protocol = "http" diff --git a/deployments/docker-compose/postgres/docker-compose.yml b/deployments/docker-compose/postgres/docker-compose.yml index b2fab41..ad57b15 100644 --- a/deployments/docker-compose/postgres/docker-compose.yml +++ b/deployments/docker-compose/postgres/docker-compose.yml @@ -1,6 +1,7 @@ volumes: postgres: pgadmin: + rabbitmq_data: services: postgres: @@ -34,3 +35,16 @@ services: - "-c" - "/bin/echo 'postgres:5432:*:postgres:secret' > /tmp/pgpassfile && chmod 600 /tmp/pgpassfile && /entrypoint.sh" restart: unless-stopped + + rabbitmq: + container_name: dev_rabbitmq + image: "rabbitmq:4-management" + environment: + RABBITMQ_DEFAULT_USER: "guest" + RABBITMQ_DEFAULT_PASS: "guest" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + ports: + - "5672:5672" + - "15672:15672" + restart: unless-stopped diff --git a/docs/src/developer-guides/architecture.md b/docs/src/developer-guides/architecture.md index 5e36e46..1885bae 100644 --- a/docs/src/developer-guides/architecture.md +++ b/docs/src/developer-guides/architecture.md @@ -2,49 +2,65 @@ FitFeed follows a microservices architecture with a focus on privacy and security. -## System Overview - -```mermaid -graph TD - User((User)) - Web[Web Frontend - React] - API[API Service - Go] - Auth[Auth Service - Go] - DB[(PostgreSQL)] - - User <-->|HTTPS| Web - Web <-->|REST/JWT| API - Web <-->|REST/OAuth/Passkey| Auth - API <-->|SQL| DB - Auth <-->|SQL| DB - Auth -.->|Provides JWT| Web -``` - ## Clean Code Pattern All Go services follow a layered approach: - **Entity:** Domain-specific data models and business logic. - **UseCase:** Application-specific business rules and use cases. -- **Controller:** Entry points (HTTP handlers) that interact with use cases. +- **Controller:** Entry points (HTTP handlers or AMQP consumers) that interact with use cases. - **Repo:** Data access layer (GORM repositories). -## Security & Authentication +## Service Independence -FitFeed provides several authentication methods to ensure user privacy: +Each service is designed to be independent and can be deployed with its own database. -- **Passkeys (WebAuthn):** Provides a secure, passwordless login experience. -- **OAuth:** Allows users to log in through popular providers like Google or GitHub. -- **JWT (JSON Web Tokens):** Used for session management and route protection across all services. +- **Auth Service:** Responsible for authentication objects (OAuth providers, Passkeys) and JWT generation. +- **API Service:** Responsible for user management, profiles, and core application logic. -## Service Communication +## Inter-Service Communication -Currently, services communicate primarily through HTTP: +Services communicate asynchronously using a message bus (RabbitMQ) to ensure loose coupling and high availability. -- **Auth Service:** Manages registration, login, and JWT generation. -- **API Service:** Main entry point for the frontend, providing profile data and application state. -- **Web Service:** The React-based frontend client. +### SAGA Pattern -## Data Layer +For operations that span multiple services (e.g., User Registration), FitFeed uses the SAGA pattern with asynchronous events and compensation logic. -The data layer is managed by a centralized `dbm` service. All services share a PostgreSQL database, but are responsible for their respective data schemas. Migrations are managed using Goose and GORM. +1. **Registration Start:** `auth` service creates local authentication records and publishes a `UserCreated` event. +2. **User Creation:** `api` service consumes the event and creates the `User` and `Profile` records. +3. **Compensation:** If `api` fails, it publishes a `UserCreationFailed` event, allowing `auth` to rollback or flag the authentication record. + +### RPC Communication + +For synchronous lookups (e.g., checking if a user exists during login), services use AMQP-based RPC calls. + +## Security & Authentication + +FitFeed provides several authentication methods: + +- **Passkeys (WebAuthn):** Secure, passwordless login. +- **OAuth:** Login via providers like Google or GitHub. +- **JWT:** Distributed session management. + +## System Overview + +```mermaid +graph TD + User((User)) + Web[Web Frontend - React] + API[API Service - Go] + Auth[Auth Service - Go] + DB_API[(PostgreSQL - API)] + DB_Auth[(PostgreSQL - Auth)] + Bus[RabbitMQ] + + User <-->|HTTPS| Web + Web <-->|REST/JWT| API + Web <-->|REST/OAuth/Passkey| Auth + + API <-->|SQL| DB_API + Auth <-->|SQL| DB_Auth + + Auth <-->|AMQP RPC/Events| Bus + API <-->|AMQP RPC/Events| Bus +``` diff --git a/services/api/README.md b/services/api/README.md index 85cc387..8a73603 100644 --- a/services/api/README.md +++ b/services/api/README.md @@ -1,3 +1,32 @@ -# FitFeed api service +# FitFeed API Service -This is a core API service. +This is the core API microservice for FitFeed. It handles user profiles, activity management, social engagement (likes and comments), and inter-service messaging. + +## Architecture + +This service strictly adheres to **Clean Architecture**: +- `internal/entity`: Domain entities (`User`, `Profile`, `Activity`, `ActivityLike`, `ActivityComment`). +- `internal/usecase`: Business orchestrators (`usermanager`, `likemanager`, `commentmanager`). +- `internal/repo`: Database persistence layer using GORM generic methods (`userdb`, `profiledb`, `socialdb`). +- `internal/controller/http/v1`: REST handlers and routing. +- `internal/controller/amqp`: RabbitMQ event consumers and RPC handlers. + +## HTTP Endpoints (v1) + +### User Management +- `GET /api/v1/users/:username`: Fetch public user profile +- `PUT /api/v1/users/profile`: Update authenticated user profile (Protected by JWT) +- `GET /api/v1/config`: Retrieve public client config + +### Social Activities, Likes & Comments +- `POST /api/v1/activities/:id/like`: Like an activity (Protected by JWT) +- `DELETE /api/v1/activities/:id/like`: Unlike an activity (Protected by JWT) +- `GET /api/v1/activities/:id/comments`: List comments in chronological order (Protected by JWT) +- `POST /api/v1/activities/:id/comments`: Post a text comment (Protected by JWT) +- `DELETE /api/v1/comments/:id`: Delete an authored comment (Protected by JWT, author check enforced) + +## Running Locally + +```bash +make dev-api +``` diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 07795c9..62ed1d1 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -1,18 +1,24 @@ package main import ( - "fitfeed/api/internal/config" - httpcontroller "fitfeed/api/internal/controller/http" - "fitfeed/api/internal/repo/profiledb" - "fitfeed/api/internal/repo/userdb" - "fitfeed/api/internal/usecase/usermanager" + "context" + "github.com/Linux-oiD/fitfeed/services/api/internal/config" + amqpcontroller "github.com/Linux-oiD/fitfeed/services/api/internal/controller/amqp" + httpcontroller "github.com/Linux-oiD/fitfeed/services/api/internal/controller/http" + "github.com/Linux-oiD/fitfeed/services/api/internal/repo/profiledb" + "github.com/Linux-oiD/fitfeed/services/api/internal/repo/socialdb" + "github.com/Linux-oiD/fitfeed/services/api/internal/repo/userdb" + "github.com/Linux-oiD/fitfeed/services/api/internal/usecase/commentmanager" + "github.com/Linux-oiD/fitfeed/services/api/internal/usecase/likemanager" + "github.com/Linux-oiD/fitfeed/services/api/internal/usecase/usermanager" + "github.com/Linux-oiD/fitfeed/services/api/pkg/amqp" "fmt" "log/slog" "net/http" "os" - "fitfeed/api/pkg/httpserver" - "fitfeed/api/pkg/postgres" + "github.com/Linux-oiD/fitfeed/services/api/pkg/httpserver" + "github.com/Linux-oiD/fitfeed/services/api/pkg/postgres" ) func main() { @@ -21,7 +27,7 @@ func main() { // Initialize slog var handler slog.Handler - if conf.IsProd { + if conf.Auth.IsProd { handler = slog.NewJSONHandler(os.Stdout, nil) } else { handler = slog.NewTextHandler(os.Stdout, nil) @@ -43,13 +49,40 @@ func main() { sqlDB, _ := db.DB() defer sqlDB.Close() + // Initialize AMQP Server & Client + amqpServer, err := amqp.NewServer(conf.RabbitMQ.URL, logger) + if err != nil { + logger.Error("AMQP server connection error", "error", err) + os.Exit(1) + } + defer amqpServer.Close() + + amqpClient, err := amqp.NewClient(conf.RabbitMQ.URL, logger) + if err != nil { + logger.Error("AMQP client connection error", "error", err) + os.Exit(1) + } + defer amqpClient.Close() + + // Repositories udb := userdb.New(db) pdb := profiledb.New(db) + sdb := socialdb.New(db) + // Usecases um := usermanager.New(udb, pdb, logger) + lm := likemanager.New(sdb, logger) + cme := commentmanager.New(sdb, logger) + + // Register AMQP handlers + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + aq := amqpcontroller.NewUserController(um, amqpServer, amqpClient, logger) + aq.RegisterHandlers(ctx) srv := httpserver.New(conf.API.Port) - srv.Handler = httpcontroller.New(um, conf) + srv.Handler = httpcontroller.New(um, lm, cme, conf) done := make(chan bool, 1) diff --git a/services/api/go.mod b/services/api/go.mod index cebeda8..c5a717b 100644 --- a/services/api/go.mod +++ b/services/api/go.mod @@ -1,4 +1,4 @@ -module fitfeed/api +module github.com/Linux-oiD/fitfeed/services/api go 1.25.0 @@ -8,13 +8,16 @@ require ( github.com/go-webauthn/webauthn v0.16.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 + github.com/rabbitmq/amqp091-go v1.10.0 github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.11.1 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.30.2 ) require ( github.com/ajg/form v1.5.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -28,11 +31,13 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/x448/float16 v0.8.4 // indirect diff --git a/services/api/go.sum b/services/api/go.sum index 78214fa..9ccafa6 100644 --- a/services/api/go.sum +++ b/services/api/go.sum @@ -51,6 +51,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= @@ -66,6 +68,8 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -78,6 +82,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= diff --git a/services/api/internal/config/config.go b/services/api/internal/config/config.go index e115fd9..4d93e5d 100644 --- a/services/api/internal/config/config.go +++ b/services/api/internal/config/config.go @@ -35,6 +35,9 @@ type AppConfig struct { DBname string `mapstructure:"dbname"` } `mapstructure:"postgres"` } `mapstructure:"database"` + RabbitMQ struct { + URL string `mapstructure:"url"` + } `mapstructure:"rabbitmq"` Web struct { Hostname string `mapstructure:"hostname"` Protocol string `mapstructure:"protocol"` diff --git a/services/api/internal/controller/http/router.go b/services/api/internal/controller/http/router.go index 1ef502e..38d8066 100644 --- a/services/api/internal/controller/http/router.go +++ b/services/api/internal/controller/http/router.go @@ -1,9 +1,9 @@ package http import ( - v1 "fitfeed/api/internal/controller/http/v1" - "fitfeed/api/internal/config" - "fitfeed/api/internal/usecase" + v1 "github.com/Linux-oiD/fitfeed/services/api/internal/controller/http/v1" + "github.com/Linux-oiD/fitfeed/services/api/internal/config" + "github.com/Linux-oiD/fitfeed/services/api/internal/usecase" "net/http" @@ -12,7 +12,7 @@ import ( "github.com/go-chi/render" ) -func New(u usecase.UserManager, conf *config.AppConfig) http.Handler { +func New(u usecase.UserManager, lm usecase.LikeManager, cm usecase.CommentManager, conf *config.AppConfig) http.Handler { r := chi.NewRouter() @@ -46,10 +46,12 @@ func New(u usecase.UserManager, conf *config.AppConfig) http.Handler { apiV1 := chi.NewRouter() - v1.NewRouter(apiV1, u, conf) + v1.NewRouter(apiV1, u, lm, cm, conf) r.Mount("/v1", apiV1) - return r + root := chi.NewRouter() + root.Mount("/api", r) + return root } diff --git a/services/api/internal/controller/http/v1/config.go b/services/api/internal/controller/http/v1/config.go index 1957c0b..ab87d9a 100644 --- a/services/api/internal/controller/http/v1/config.go +++ b/services/api/internal/controller/http/v1/config.go @@ -2,7 +2,7 @@ package v1 import ( "encoding/json" - "fitfeed/api/internal/config" + "github.com/Linux-oiD/fitfeed/services/api/internal/config" "net/http" ) @@ -15,14 +15,23 @@ func NewConfigController(conf *config.AppConfig) *ConfigController { } type WebConfig struct { - AuthURL string `json:"auth_url"` - APIURL string `json:"api_url"` + AuthURL string `json:"auth_url"` + APIURL string `json:"api_url"` + Providers []string `json:"providers"` } func (c *ConfigController) GetConfig(w http.ResponseWriter, r *http.Request) { + var enabledProviders []string + for name, provider := range c.conf.Auth.Providers { + if provider.Enabled { + enabledProviders = append(enabledProviders, name) + } + } + webConf := WebConfig{ - AuthURL: "http://localhost:8081", // In prod get from conf - APIURL: "http://localhost:8082", // In prod get from conf + AuthURL: "/auth", + APIURL: "/api", + Providers: enabledProviders, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(webConf) diff --git a/services/api/internal/controller/http/v1/middleware.go b/services/api/internal/controller/http/v1/middleware.go index 9d45768..60952bd 100644 --- a/services/api/internal/controller/http/v1/middleware.go +++ b/services/api/internal/controller/http/v1/middleware.go @@ -3,7 +3,7 @@ package v1 import ( "context" "errors" - "fitfeed/api/internal/entity" + "github.com/Linux-oiD/fitfeed/services/api/internal/entity" "net/http" "strings" diff --git a/services/api/internal/controller/http/v1/router.go b/services/api/internal/controller/http/v1/router.go index d96ba04..9f93a97 100644 --- a/services/api/internal/controller/http/v1/router.go +++ b/services/api/internal/controller/http/v1/router.go @@ -1,19 +1,21 @@ package v1 import ( - "fitfeed/api/internal/config" - "fitfeed/api/internal/usecase" + "github.com/Linux-oiD/fitfeed/services/api/internal/config" + "github.com/Linux-oiD/fitfeed/services/api/internal/usecase" "github.com/go-chi/chi/v5" ) -func NewRouter(r chi.Router, u usecase.UserManager, conf *config.AppConfig) { +func NewRouter(r chi.Router, u usecase.UserManager, lm usecase.LikeManager, cm usecase.CommentManager, conf *config.AppConfig) { uc := NewUserController(u) cc := NewConfigController(conf) + lc := NewLikeController(lm) + cmc := NewCommentController(cm) r.Route("/users", func(r chi.Router) { r.Get("/{username}", uc.GetProfile) - + // Protected routes r.Group(func(r chi.Router) { r.Use(JWTMiddleware(conf.Auth.Secret)) @@ -22,4 +24,21 @@ func NewRouter(r chi.Router, u usecase.UserManager, conf *config.AppConfig) { }) r.Get("/config", cc.GetConfig) + + // Social routes — all protected by JWT + r.Route("/activities", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(JWTMiddleware(conf.Auth.Secret)) + r.Post("/{id}/like", lc.LikeActivity) + r.Delete("/{id}/like", lc.UnlikeActivity) + r.Get("/{id}/comments", cmc.GetComments) + r.Post("/{id}/comments", cmc.CreateComment) + }) + }) + + // Comment deletion — protected by JWT + r.Group(func(r chi.Router) { + r.Use(JWTMiddleware(conf.Auth.Secret)) + r.Delete("/comments/{id}", cmc.DeleteComment) + }) } diff --git a/services/api/internal/controller/http/v1/user.go b/services/api/internal/controller/http/v1/user.go index ed84a81..04bea34 100644 --- a/services/api/internal/controller/http/v1/user.go +++ b/services/api/internal/controller/http/v1/user.go @@ -2,8 +2,9 @@ package v1 import ( "encoding/json" - "fitfeed/api/internal/entity" - "fitfeed/api/internal/usecase" + "errors" + "github.com/Linux-oiD/fitfeed/services/api/internal/entity" + "github.com/Linux-oiD/fitfeed/services/api/internal/usecase" "net/http" "github.com/go-chi/chi/v5" @@ -21,9 +22,14 @@ func (c *UserController) GetProfile(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") user, err := c.u.GetProfile(r.Context(), username) if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + if errors.Is(err, entity.ENOTFOUND) { + http.Error(w, "user not found", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } @@ -42,10 +48,15 @@ func (c *UserController) UpdateProfile(w http.ResponseWriter, r *http.Request) { err = c.u.UpdateProfile(r.Context(), claims.ID, profile) if err != nil { + if errors.Is(err, entity.ECONFLICT) { + http.Error(w, "email already taken", http.StatusConflict) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"message": "profile updated"}) } diff --git a/services/api/internal/entity/passkey.go b/services/api/internal/entity/passkey.go index ec66fcb..d22f804 100644 --- a/services/api/internal/entity/passkey.go +++ b/services/api/internal/entity/passkey.go @@ -13,6 +13,8 @@ type Passkey struct { AttestationType string `gorm:"size:255" json:"attestation_type"` AAGUID []byte `gorm:"type:bytea" json:"aaguid"` SignCount uint32 `json:"sign_count"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` } func (p Passkey) WebAuthnCredential() webauthn.Credential { @@ -20,6 +22,12 @@ func (p Passkey) WebAuthnCredential() webauthn.Credential { ID: p.CredentialID, PublicKey: p.PublicKey, AttestationType: p.AttestationType, + Flags: webauthn.CredentialFlags{ + UserPresent: true, + UserVerified: true, + BackupEligible: p.BackupEligible, + BackupState: p.BackupState, + }, Authenticator: webauthn.Authenticator{ AAGUID: p.AAGUID, SignCount: p.SignCount, diff --git a/services/api/internal/entity/profile.go b/services/api/internal/entity/profile.go index aef5692..e59952d 100644 --- a/services/api/internal/entity/profile.go +++ b/services/api/internal/entity/profile.go @@ -1,20 +1,26 @@ package entity -import "github.com/google/uuid" +import ( + "time" + + "github.com/google/uuid" +) // Profile is the model for the profile table. type Profile struct { Base - FirstName string `gorm:"size:255" json:"first_name"` - LastName string `gorm:"size:255" json:"last_name"` - AvatarURL string `gorm:"size:255" json:"avatar_url"` - Email string `gorm:"uniqueIndex" json:"email"` - UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` + FirstName string `gorm:"size:255" json:"first_name"` + LastName string `gorm:"size:255" json:"last_name"` + AvatarURL string `gorm:"size:255" json:"avatar_url"` + Email string `gorm:"uniqueIndex" json:"email"` + DateOfBirth *time.Time `json:"date_of_birth"` + UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` } type ProfileUpdate struct { - FirsrName string - LastName string - AvatarURL string - Email string + FirstName string + LastName string + AvatarURL string + Email string + DateOfBirth *time.Time } diff --git a/services/api/internal/repo/contracts.go b/services/api/internal/repo/contracts.go index e505628..2dd61ed 100644 --- a/services/api/internal/repo/contracts.go +++ b/services/api/internal/repo/contracts.go @@ -2,19 +2,40 @@ package repo import ( "context" - "fitfeed/api/internal/entity" + "github.com/Linux-oiD/fitfeed/services/api/internal/entity" "github.com/google/uuid" ) type ( UserDB interface { + Create(context.Context, *entity.User) error GetByID(context.Context, uuid.UUID) (entity.User, error) GetByUsername(context.Context, string) (entity.User, error) } ProfileDB interface { + Create(context.Context, entity.Profile) error GetByUserID(context.Context, uuid.UUID) (entity.Profile, error) + GetByEmail(context.Context, string) (entity.Profile, error) Update(context.Context, uuid.UUID, entity.Profile) error } + + SocialDB interface { + // Activity + GetActivity(ctx context.Context, id uuid.UUID) (entity.Activity, error) + + // Likes + LikeActivity(ctx context.Context, userID, activityID uuid.UUID) (int64, error) + UnlikeActivity(ctx context.Context, userID, activityID uuid.UUID) (int64, error) + GetLikeCount(ctx context.Context, activityID uuid.UUID) (int64, error) + IsLiked(ctx context.Context, userID, activityID uuid.UUID) (bool, error) + + // Comments + CreateComment(ctx context.Context, comment *entity.ActivityComment) error + GetComments(ctx context.Context, activityID uuid.UUID) ([]entity.ActivityComment, error) + GetComment(ctx context.Context, id uuid.UUID) (entity.ActivityComment, error) + DeleteComment(ctx context.Context, id uuid.UUID) error + GetCommentCount(ctx context.Context, activityID uuid.UUID) (int64, error) + } ) diff --git a/services/api/internal/repo/profiledb/profiledb.go b/services/api/internal/repo/profiledb/profiledb.go index c4a33fa..4ba6604 100644 --- a/services/api/internal/repo/profiledb/profiledb.go +++ b/services/api/internal/repo/profiledb/profiledb.go @@ -2,7 +2,7 @@ package profiledb import ( "context" - "fitfeed/api/internal/entity" + "github.com/Linux-oiD/fitfeed/services/api/internal/entity" "github.com/google/uuid" "gorm.io/gorm" @@ -16,12 +16,24 @@ func New(db *gorm.DB) *ProfileDB { return &ProfileDB{db: db} } +func (p *ProfileDB) Create(ctx context.Context, profile entity.Profile) error { + err := gorm.G[entity.Profile](p.db).Create(ctx, &profile) + return err +} + func (p *ProfileDB) GetByUserID(ctx context.Context, userID uuid.UUID) (entity.Profile, error) { profile, err := gorm.G[entity.Profile](p.db).Where("user_id = ?", userID).First(ctx) return profile, err } +func (p *ProfileDB) GetByEmail(ctx context.Context, email string) (entity.Profile, error) { + profile, err := gorm.G[entity.Profile](p.db).Where("email = ?", email).First(ctx) + return profile, err +} + func (p *ProfileDB) Update(ctx context.Context, userID uuid.UUID, profile entity.Profile) error { + // We use Updates with a map or struct to only update provided fields. + // GORM's G wrapper should handle this. _, err := gorm.G[entity.Profile](p.db).Where("user_id = ?", userID).Updates(ctx, profile) return err } diff --git a/services/api/internal/repo/userdb/userdb.go b/services/api/internal/repo/userdb/userdb.go index 602574f..1fae8cc 100644 --- a/services/api/internal/repo/userdb/userdb.go +++ b/services/api/internal/repo/userdb/userdb.go @@ -2,7 +2,7 @@ package userdb import ( "context" - "fitfeed/api/internal/entity" + "github.com/Linux-oiD/fitfeed/services/api/internal/entity" "github.com/google/uuid" "gorm.io/gorm" @@ -16,12 +16,17 @@ func New(db *gorm.DB) *UserDB { return &UserDB{db: db} } +func (u *UserDB) Create(ctx context.Context, user *entity.User) error { + err := gorm.G[entity.User](u.db).Create(ctx, user) + return err +} + func (u *UserDB) GetByID(ctx context.Context, id uuid.UUID) (entity.User, error) { - user, err := gorm.G[entity.User](u.db).Where("id = ?", id).First(ctx) + user, err := gorm.G[entity.User](u.db).Preload("Profile", func(db gorm.PreloadBuilder) error { return nil }).Where("id = ?", id).First(ctx) return user, err } func (u *UserDB) GetByUsername(ctx context.Context, username string) (entity.User, error) { - user, err := gorm.G[entity.User](u.db).Where("username = ?", username).First(ctx) + user, err := gorm.G[entity.User](u.db).Preload("Profile", func(db gorm.PreloadBuilder) error { return nil }).Where("username = ?", username).First(ctx) return user, err } diff --git a/services/api/internal/usecase/contracts.go b/services/api/internal/usecase/contracts.go index b2531b1..065784e 100644 --- a/services/api/internal/usecase/contracts.go +++ b/services/api/internal/usecase/contracts.go @@ -2,7 +2,7 @@ package usecase import ( "context" - "fitfeed/api/internal/entity" + "github.com/Linux-oiD/fitfeed/services/api/internal/entity" "github.com/google/uuid" ) @@ -10,6 +10,18 @@ import ( type ( UserManager interface { GetProfile(ctx context.Context, username string) (entity.User, error) + CreateUser(ctx context.Context, user entity.User) error UpdateProfile(ctx context.Context, id uuid.UUID, profile entity.Profile) error } + + LikeManager interface { + LikeActivity(ctx context.Context, userID, activityID uuid.UUID) (count int64, err error) + UnlikeActivity(ctx context.Context, userID, activityID uuid.UUID) (count int64, err error) + } + + CommentManager interface { + GetComments(ctx context.Context, activityID uuid.UUID) ([]entity.ActivityComment, error) + CreateComment(ctx context.Context, userID, activityID uuid.UUID, content string) (entity.ActivityComment, error) + DeleteComment(ctx context.Context, commentID, requestingUserID uuid.UUID) error + } ) diff --git a/services/api/internal/usecase/usermanager/usermanager.go b/services/api/internal/usecase/usermanager/usermanager.go index 780c7c2..dbc81c6 100644 --- a/services/api/internal/usecase/usermanager/usermanager.go +++ b/services/api/internal/usecase/usermanager/usermanager.go @@ -2,11 +2,13 @@ package usermanager import ( "context" - "fitfeed/api/internal/entity" - "fitfeed/api/internal/repo" + "errors" + "github.com/Linux-oiD/fitfeed/services/api/internal/entity" + "github.com/Linux-oiD/fitfeed/services/api/internal/repo" "log/slog" "github.com/google/uuid" + "gorm.io/gorm" ) type UserManager struct { @@ -36,7 +38,48 @@ func (u *UserManager) GetProfile(ctx context.Context, username string) (entity.U return user, nil } +func (u *UserManager) CreateUser(ctx context.Context, user entity.User) error { + // 1. Check if user already exists + _, err := u.userDB.GetByUsername(ctx, user.Username) + if err == nil { + return entity.ECONFLICT + } + + // 2. Create user + err = u.userDB.Create(ctx, &user) + if err != nil { + u.logger.Error("failed to create user", "error", err, "username", user.Username) + return entity.EINTERNAL + } + + // 3. Create profile + user.Profile.UserID = user.ID + err = u.profileDB.Create(ctx, user.Profile) + if err != nil { + u.logger.Error("failed to create profile for user", "error", err, "username", user.Username) + return entity.EINTERNAL + } + + return nil +} + func (u *UserManager) UpdateProfile(ctx context.Context, id uuid.UUID, profile entity.Profile) error { + // 1. If email is being changed, check if it's already taken + if profile.Email != "" { + existing, err := u.profileDB.GetByEmail(ctx, profile.Email) + if err == nil { + // Found someone with this email + if existing.UserID != id { + u.logger.Warn("email already taken", "email", profile.Email, "user_id", id) + return entity.ECONFLICT + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + u.logger.Error("failed to check email existence", "error", err, "email", profile.Email) + return entity.EINTERNAL + } + } + + // 2. Perform the update err := u.profileDB.Update(ctx, id, profile) if err != nil { u.logger.Error("failed to update profile", "error", err, "user_id", id) diff --git a/services/api/internal/usecase/usermanager/usermanager_test.go b/services/api/internal/usecase/usermanager/usermanager_test.go new file mode 100644 index 0000000..5fe4d86 --- /dev/null +++ b/services/api/internal/usecase/usermanager/usermanager_test.go @@ -0,0 +1,115 @@ +package usermanager + +import ( + "context" + "github.com/Linux-oiD/fitfeed/services/api/internal/entity" + "log/slog" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "gorm.io/gorm" +) + +type mockUserDB struct { + mock.Mock +} + +func (m *mockUserDB) Create(ctx context.Context, u *entity.User) error { + args := m.Called(ctx, u) + return args.Error(0) +} + +func (m *mockUserDB) GetByID(ctx context.Context, id uuid.UUID) (entity.User, error) { + args := m.Called(ctx, id) + return args.Get(0).(entity.User), args.Error(1) +} + +func (m *mockUserDB) GetByUsername(ctx context.Context, username string) (entity.User, error) { + args := m.Called(ctx, username) + return args.Get(0).(entity.User), args.Error(1) +} + +type mockProfileDB struct { + mock.Mock +} + +func (m *mockProfileDB) Create(ctx context.Context, p entity.Profile) error { + args := m.Called(ctx, p) + return args.Error(0) +} + +func (m *mockProfileDB) GetByUserID(ctx context.Context, userID uuid.UUID) (entity.Profile, error) { + args := m.Called(ctx, userID) + return args.Get(0).(entity.Profile), args.Error(1) +} + +func (m *mockProfileDB) GetByEmail(ctx context.Context, email string) (entity.Profile, error) { + args := m.Called(ctx, email) + return args.Get(0).(entity.Profile), args.Error(1) +} + +func (m *mockProfileDB) Update(ctx context.Context, userID uuid.UUID, profile entity.Profile) error { + args := m.Called(ctx, userID, profile) + return args.Error(0) +} + +func TestCreateUser(t *testing.T) { + udb := new(mockUserDB) + pdb := new(mockProfileDB) + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + um := New(udb, pdb, logger) + + ctx := context.Background() + user := entity.User{Username: "test", Base: entity.Base{ID: uuid.New()}} + + t.Run("success", func(t *testing.T) { + udb.On("GetByUsername", ctx, "test").Return(entity.User{}, gorm.ErrRecordNotFound).Once() + udb.On("Create", ctx, &user).Return(nil).Once() + pdb.On("Create", ctx, mock.Anything).Return(nil).Once() + + err := um.CreateUser(ctx, user) + assert.NoError(t, err) + }) + + t.Run("already exists", func(t *testing.T) { + udb.On("GetByUsername", ctx, "test").Return(user, nil).Once() + + err := um.CreateUser(ctx, user) + assert.Equal(t, entity.ECONFLICT, err) + }) +} + +func TestUpdateProfile(t *testing.T) { + udb := new(mockUserDB) + pdb := new(mockProfileDB) + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + um := New(udb, pdb, logger) + + ctx := context.Background() + userID := uuid.New() + + t.Run("success", func(t *testing.T) { + profile := entity.Profile{Email: "new@example.com"} + + // 1. Check email uniqueness + pdb.On("GetByEmail", ctx, "new@example.com").Return(entity.Profile{}, gorm.ErrRecordNotFound).Once() + // 2. Update + pdb.On("Update", ctx, userID, profile).Return(nil).Once() + + err := um.UpdateProfile(ctx, userID, profile) + assert.NoError(t, err) + }) + + t.Run("email conflict", func(t *testing.T) { + profile := entity.Profile{Email: "taken@example.com"} + otherUserID := uuid.New() + + pdb.On("GetByEmail", ctx, "taken@example.com").Return(entity.Profile{UserID: otherUserID}, nil).Once() + + err := um.UpdateProfile(ctx, userID, profile) + assert.Equal(t, entity.ECONFLICT, err) + }) +} diff --git a/services/api/tmp/build-errors.log b/services/api/tmp/build-errors.log deleted file mode 100644 index 571fc8e..0000000 --- a/services/api/tmp/build-errors.log +++ /dev/null @@ -1 +0,0 @@ -exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/services/auth/cmd/auth/main.go b/services/auth/cmd/auth/main.go index 2f407f0..8e63945 100644 --- a/services/auth/cmd/auth/main.go +++ b/services/auth/cmd/auth/main.go @@ -1,26 +1,28 @@ package main import ( - "fitfeed/auth/internal/config" - httpcontroller "fitfeed/auth/internal/controller/http" - "fitfeed/auth/internal/oauth" - "fitfeed/auth/internal/repo/oauthdb" - "fitfeed/auth/internal/repo/passkeydb" - "fitfeed/auth/internal/repo/profiledb" - "fitfeed/auth/internal/repo/userdb" - "fitfeed/auth/internal/usecase/jwtmanager" - "fitfeed/auth/internal/usecase/oauthmanager" - "fitfeed/auth/internal/usecase/passkeymanager" - "fitfeed/auth/internal/usecase/profilemanager" - "fitfeed/auth/internal/usecase/usermanager" + "context" + "github.com/Linux-oiD/fitfeed/services/auth/internal/config" + amqpcontroller "github.com/Linux-oiD/fitfeed/services/auth/internal/controller/amqp" + httpcontroller "github.com/Linux-oiD/fitfeed/services/auth/internal/controller/http" + "github.com/Linux-oiD/fitfeed/services/auth/internal/oauth" + "github.com/Linux-oiD/fitfeed/services/auth/internal/repo/oauthdb" + "github.com/Linux-oiD/fitfeed/services/auth/internal/repo/passkeydb" + "github.com/Linux-oiD/fitfeed/services/auth/internal/repo/userdb" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase/jwtmanager" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase/oauthmanager" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase/passkeymanager" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase/profilemanager" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase/usermanager" + "github.com/Linux-oiD/fitfeed/services/auth/pkg/amqp" "fmt" "log/slog" "net/http" "os" "time" - "fitfeed/auth/pkg/httpserver" - "fitfeed/auth/pkg/postgres" + "github.com/Linux-oiD/fitfeed/services/auth/pkg/httpserver" + "github.com/Linux-oiD/fitfeed/services/auth/pkg/postgres" "github.com/go-webauthn/webauthn/webauthn" ) @@ -54,16 +56,37 @@ func main() { sqlDB, _ := db.DB() defer sqlDB.Close() + // Initialize AMQP Client & Server + amqpClient, err := amqp.NewClient(conf.RabbitMQ.URL, logger) + if err != nil { + logger.Error("AMQP client connection error", "error", err) + os.Exit(1) + } + defer amqpClient.Close() + + amqpServer, err := amqp.NewServer(conf.RabbitMQ.URL, logger) + if err != nil { + logger.Error("AMQP server connection error", "error", err) + os.Exit(1) + } + defer amqpServer.Close() + udb := userdb.New(db) - pdb := profiledb.New(db) odb := oauthdb.New(db) pkdb := passkeydb.New(db) - um := usermanager.New(udb, pdb, logger) + um := usermanager.New(udb, amqpClient, logger) om := oauthmanager.New(odb, logger) - pm := profilemanager.New(pdb, logger) + pm := profilemanager.New(nil, logger) // Profile management moved to API jm := jwtmanager.New(conf.Auth.Secret, time.Duration(conf.Auth.MaxAge)*time.Second) + // Register AMQP handlers (Compensation) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + comp := amqpcontroller.NewCompensationController(amqpServer, pkdb, odb, logger) + comp.RegisterHandlers(ctx) + // WebAuthn configuration w, err := webauthn.New(&webauthn.Config{ RPDisplayName: "FitFeed", @@ -74,10 +97,10 @@ func main() { logger.Error("failed to create webauthn instance", "error", err) os.Exit(1) } - pkm := passkeymanager.New(w, pkdb, udb, logger) + pkm := passkeymanager.New(w, pkdb, um, logger) srv := httpserver.New(conf.Auth.Port) - srv.Handler = httpcontroller.New(um, om, pm, jm, pkm) + srv.Handler = httpcontroller.New(um, om, pm, jm, pkm, conf) done := make(chan bool, 1) diff --git a/services/auth/go.mod b/services/auth/go.mod index bb8146e..3ef71d2 100644 --- a/services/auth/go.mod +++ b/services/auth/go.mod @@ -1,4 +1,4 @@ -module fitfeed/auth +module github.com/Linux-oiD/fitfeed/services/auth go 1.25.0 @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 github.com/markbates/goth v1.81.0 + github.com/rabbitmq/amqp091-go v1.10.0 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.11.1 gorm.io/driver/postgres v1.6.0 diff --git a/services/auth/go.sum b/services/auth/go.sum index a95df5d..13ce09d 100644 --- a/services/auth/go.sum +++ b/services/auth/go.sum @@ -65,6 +65,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= @@ -94,6 +96,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= diff --git a/services/auth/internal/config/config.go b/services/auth/internal/config/config.go index 218c574..8eef861 100644 --- a/services/auth/internal/config/config.go +++ b/services/auth/internal/config/config.go @@ -31,6 +31,9 @@ type AppConfig struct { DBname string `mapstructure:"dbname"` } `mapstructure:"postgres"` } `mapstructure:"database"` + RabbitMQ struct { + URL string `mapstructure:"url"` + } `mapstructure:"rabbitmq"` Web struct { Hostname string `mapstructure:"hostname"` Protocol string `mapstructure:"protocol"` diff --git a/services/auth/internal/controller/http/router.go b/services/auth/internal/controller/http/router.go index 12f50d8..80adc41 100644 --- a/services/auth/internal/controller/http/router.go +++ b/services/auth/internal/controller/http/router.go @@ -1,8 +1,9 @@ package http import ( - v1 "fitfeed/auth/internal/controller/http/v1" - "fitfeed/auth/internal/usecase" + v1 "github.com/Linux-oiD/fitfeed/services/auth/internal/controller/http/v1" + "github.com/Linux-oiD/fitfeed/services/auth/internal/config" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase" "net/http" @@ -11,7 +12,7 @@ import ( "github.com/go-chi/render" ) -func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager, pk usecase.PasskeyManager) http.Handler { +func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager, pk usecase.PasskeyManager, conf *config.AppConfig) http.Handler { r := chi.NewRouter() @@ -49,10 +50,13 @@ func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager apiV1 := chi.NewRouter() - v1.NewAuthRoutes(apiV1, u, o, p, j, pk) + v1.NewAuthRoutes(apiV1, u, o, p, j, pk, conf) r.Mount("/v1", apiV1) - return r + root := chi.NewRouter() + root.Mount("/auth", r) + + return root } diff --git a/services/auth/internal/controller/http/v1/controller.go b/services/auth/internal/controller/http/v1/controller.go index 5b21a93..6dc2ee6 100644 --- a/services/auth/internal/controller/http/v1/controller.go +++ b/services/auth/internal/controller/http/v1/controller.go @@ -1,7 +1,7 @@ package v1 import ( - "fitfeed/auth/internal/usecase" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase" ) type V1 struct { diff --git a/services/auth/internal/controller/http/v1/oauth.go b/services/auth/internal/controller/http/v1/oauth.go index ccee9ad..130b36b 100644 --- a/services/auth/internal/controller/http/v1/oauth.go +++ b/services/auth/internal/controller/http/v1/oauth.go @@ -2,13 +2,15 @@ package v1 import ( "context" + "encoding/json" "errors" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "fmt" "net/http" "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/markbates/goth/gothic" ) @@ -24,8 +26,26 @@ func (h *V1) getAuthCallbackFunction(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var user entity.User + // Check if user is already logged in (account linking) + claims, err := GetUserFromContext(ctx) + if err == nil { + // User is logged in, link this provider to the current user + err = h.o.AddProvider(ctx, entity.OauthProvider{ + UserID: claims.ID, + Provider: provider, + ProviderID: gothUser.UserID, + }) + if err != nil { + http.Error(w, "failed to link provider", http.StatusInternalServerError) + return + } + // Redirect back to settings + http.Redirect(w, r, "http://localhost:5173/settings", http.StatusFound) + return + } + // 1. Check if this OAuth provider is already linked - _, err = h.o.GetByProviderID(ctx, gothUser.UserID) + oauthProvider, err := h.o.GetByProviderID(ctx, gothUser.UserID) if err == nil { // Found user by OAuth provider user, err = h.u.GetByUsername(ctx, gothUser.NickName) // Assuming NickName is username @@ -34,8 +54,10 @@ func (h *V1) getAuthCallbackFunction(w http.ResponseWriter, r *http.Request) { return } // If user not found by username but oauth exists, we might have a data inconsistency - // or we should use UserID from oauthProvider - // For now, let's assume we find them. + if errors.Is(err, entity.ENOTFOUND) { + // Try to use UserID from oauthProvider + user = entity.User{Base: entity.Base{ID: oauthProvider.UserID}, Username: gothUser.NickName} + } } else if errors.Is(err, entity.ENOTFOUND) { // 2. Provider not found. Check if user with this username exists user, err = h.u.GetByUsername(ctx, gothUser.NickName) @@ -101,6 +123,46 @@ func (h *V1) getAuthCallbackFunction(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "http://localhost:5173/", http.StatusFound) } +func (h *V1) listProviders(w http.ResponseWriter, r *http.Request) { + claims, err := GetUserFromContext(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + providers, err := h.o.ListProviders(r.Context(), claims.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(providers) +} + +func (h *V1) unlinkProvider(w http.ResponseWriter, r *http.Request) { + _, err := GetUserFromContext(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + idStr := chi.URLParam(r, "id") + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + // TODO: Verify ownership before deleting + err = h.o.DeleteProvider(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (h *V1) getAuthFunction(w http.ResponseWriter, r *http.Request) { provider := chi.URLParam(r, "provider") diff --git a/services/auth/internal/controller/http/v1/passkey.go b/services/auth/internal/controller/http/v1/passkey.go index d2a1186..5b3305e 100644 --- a/services/auth/internal/controller/http/v1/passkey.go +++ b/services/auth/internal/controller/http/v1/passkey.go @@ -2,12 +2,14 @@ package v1 import ( "encoding/json" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "log/slog" "net/http" "time" + "github.com/go-chi/chi/v5" "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" "github.com/markbates/goth/gothic" ) @@ -130,3 +132,44 @@ func (h *V1) finishLogin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } + +func (h *V1) listPasskeys(w http.ResponseWriter, r *http.Request) { + claims, err := GetUserFromContext(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + pks, err := h.pk.ListPasskeys(r.Context(), claims.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(pks) +} + +func (h *V1) deletePasskey(w http.ResponseWriter, r *http.Request) { + _, err := GetUserFromContext(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + idStr := chi.URLParam(r, "id") + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + // TODO: Verify ownership + err = h.pk.DeletePasskey(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/services/auth/internal/controller/http/v1/router.go b/services/auth/internal/controller/http/v1/router.go index 73ff436..051c558 100644 --- a/services/auth/internal/controller/http/v1/router.go +++ b/services/auth/internal/controller/http/v1/router.go @@ -1,7 +1,8 @@ package v1 import ( - "fitfeed/auth/internal/usecase" + "github.com/Linux-oiD/fitfeed/services/auth/internal/config" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase" "github.com/go-chi/chi/v5" ) @@ -16,7 +17,7 @@ func NewUserRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p }) } -func NewAuthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager, pk usecase.PasskeyManager) { +func NewAuthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager, pk usecase.PasskeyManager, conf *config.AppConfig) { h := &V1{u: u, o: o, p: p, j: j, pk: pk} @@ -25,6 +26,12 @@ func NewAuthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p r.Get("/{provider}/auth", h.getAuthFunction) r.Get("/{provider}/logout", h.getLogoutFunction) + // Protected routes (require JWT) + r.Group(func(r chi.Router) { + r.Use(JWTMiddleware(conf.Auth.Secret)) + r.Get("/", h.listProviders) + r.Delete("/{id}", h.unlinkProvider) + }) }) r.Route("/passkey", func(r chi.Router) { @@ -32,5 +39,12 @@ func NewAuthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p r.Post("/register/finish", h.finishRegistration) r.Get("/login/begin", h.beginLogin) r.Post("/login/finish", h.finishLogin) + + // Protected + r.Group(func(r chi.Router) { + r.Use(JWTMiddleware(conf.Auth.Secret)) + r.Get("/", h.listPasskeys) + r.Delete("/{id}", h.deletePasskey) + }) }) } diff --git a/services/auth/internal/entity/passkey.go b/services/auth/internal/entity/passkey.go index ec66fcb..d22f804 100644 --- a/services/auth/internal/entity/passkey.go +++ b/services/auth/internal/entity/passkey.go @@ -13,6 +13,8 @@ type Passkey struct { AttestationType string `gorm:"size:255" json:"attestation_type"` AAGUID []byte `gorm:"type:bytea" json:"aaguid"` SignCount uint32 `json:"sign_count"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` } func (p Passkey) WebAuthnCredential() webauthn.Credential { @@ -20,6 +22,12 @@ func (p Passkey) WebAuthnCredential() webauthn.Credential { ID: p.CredentialID, PublicKey: p.PublicKey, AttestationType: p.AttestationType, + Flags: webauthn.CredentialFlags{ + UserPresent: true, + UserVerified: true, + BackupEligible: p.BackupEligible, + BackupState: p.BackupState, + }, Authenticator: webauthn.Authenticator{ AAGUID: p.AAGUID, SignCount: p.SignCount, diff --git a/services/auth/internal/entity/profile.go b/services/auth/internal/entity/profile.go index aef5692..55f2680 100644 --- a/services/auth/internal/entity/profile.go +++ b/services/auth/internal/entity/profile.go @@ -1,20 +1,26 @@ package entity -import "github.com/google/uuid" +import ( + "time" + + "github.com/google/uuid" +) // Profile is the model for the profile table. type Profile struct { Base - FirstName string `gorm:"size:255" json:"first_name"` - LastName string `gorm:"size:255" json:"last_name"` - AvatarURL string `gorm:"size:255" json:"avatar_url"` - Email string `gorm:"uniqueIndex" json:"email"` - UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` + FirstName string `gorm:"size:255" json:"first_name"` + LastName string `gorm:"size:255" json:"last_name"` + AvatarURL string `gorm:"size:255" json:"avatar_url"` + Email string `gorm:"uniqueIndex" json:"email"` + DateOfBirth *time.Time `json:"date_of_birth"` + UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` } type ProfileUpdate struct { - FirsrName string - LastName string - AvatarURL string - Email string + FirsrName string + LastName string + AvatarURL string + Email string + DateOfBirth *time.Time } diff --git a/services/auth/internal/oauth/oauth.go b/services/auth/internal/oauth/oauth.go index 3edf351..534f832 100644 --- a/services/auth/internal/oauth/oauth.go +++ b/services/auth/internal/oauth/oauth.go @@ -1,7 +1,7 @@ package oauth import ( - "fitfeed/auth/internal/config" + "github.com/Linux-oiD/fitfeed/services/auth/internal/config" "fmt" "log" "net/http" diff --git a/services/auth/internal/repo/contracts.go b/services/auth/internal/repo/contracts.go index 11ad591..e92503c 100644 --- a/services/auth/internal/repo/contracts.go +++ b/services/auth/internal/repo/contracts.go @@ -3,7 +3,7 @@ package repo import ( "context" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "github.com/google/uuid" ) @@ -28,6 +28,7 @@ type ( Create(context.Context, entity.OauthProvider) error GetByID(context.Context, uuid.UUID) (entity.OauthProvider, error) GetByProviderID(context.Context, string) (entity.OauthProvider, error) + GetByUserID(context.Context, uuid.UUID) ([]entity.OauthProvider, error) UpdateProviderID(context.Context, uuid.UUID, string) error Delete(context.Context, uuid.UUID) error } @@ -35,6 +36,7 @@ type ( PasskeyDB interface { Create(context.Context, entity.Passkey) error GetByCredentialID(context.Context, []byte) (entity.Passkey, error) + GetByUserID(context.Context, uuid.UUID) ([]entity.Passkey, error) UpdateSignCount(context.Context, []byte, uint32) error Delete(context.Context, uuid.UUID) error } diff --git a/services/auth/internal/repo/oauthdb/oauthdb.go b/services/auth/internal/repo/oauthdb/oauthdb.go index 1ddb305..3aac456 100644 --- a/services/auth/internal/repo/oauthdb/oauthdb.go +++ b/services/auth/internal/repo/oauthdb/oauthdb.go @@ -3,7 +3,7 @@ package oauthdb import ( "context" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "github.com/google/uuid" "gorm.io/gorm" @@ -31,13 +31,18 @@ func (o *OauthDB) GetByID(ctx context.Context, id uuid.UUID) (entity.OauthProvid } func (o *OauthDB) GetByProviderID(ctx context.Context, providerID string) (entity.OauthProvider, error) { - provider, err := gorm.G[entity.OauthProvider](o.db).Where("provider_id = ?", providerID).First(ctx) return provider, err } +func (o *OauthDB) GetByUserID(ctx context.Context, userID uuid.UUID) ([]entity.OauthProvider, error) { + providers, err := gorm.G[entity.OauthProvider](o.db).Where("user_id = ?", userID).Find(ctx) + return providers, err +} + func (o *OauthDB) UpdateProviderID(ctx context.Context, id uuid.UUID, providerID string) error { + _, err := gorm.G[entity.OauthProvider](o.db).Where("id = ?", id).Update(ctx, "provider_id", providerID) return err } diff --git a/services/auth/internal/repo/passkeydb/passkeydb.go b/services/auth/internal/repo/passkeydb/passkeydb.go index 70de7d4..9f4cb0e 100644 --- a/services/auth/internal/repo/passkeydb/passkeydb.go +++ b/services/auth/internal/repo/passkeydb/passkeydb.go @@ -2,7 +2,7 @@ package passkeydb import ( "context" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "github.com/google/uuid" "gorm.io/gorm" @@ -26,6 +26,11 @@ func (p *PasskeyDB) GetByCredentialID(ctx context.Context, credentialID []byte) return pk, err } +func (p *PasskeyDB) GetByUserID(ctx context.Context, userID uuid.UUID) ([]entity.Passkey, error) { + pks, err := gorm.G[entity.Passkey](p.db).Where("user_id = ?", userID).Find(ctx) + return pks, err +} + func (p *PasskeyDB) UpdateSignCount(ctx context.Context, credentialID []byte, signCount uint32) error { _, err := gorm.G[entity.Passkey](p.db).Where("credential_id = ?", credentialID).Update(ctx, "sign_count", signCount) return err diff --git a/services/auth/internal/repo/profiledb/profiledb.go b/services/auth/internal/repo/profiledb/profiledb.go index 687cce6..0862538 100644 --- a/services/auth/internal/repo/profiledb/profiledb.go +++ b/services/auth/internal/repo/profiledb/profiledb.go @@ -3,7 +3,7 @@ package profiledb import ( "context" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "github.com/google/uuid" "gorm.io/gorm" diff --git a/services/auth/internal/repo/userdb/userdb.go b/services/auth/internal/repo/userdb/userdb.go index 708d5dd..1e2b5a7 100644 --- a/services/auth/internal/repo/userdb/userdb.go +++ b/services/auth/internal/repo/userdb/userdb.go @@ -3,7 +3,7 @@ package userdb import ( "context" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "github.com/google/uuid" "gorm.io/gorm" @@ -25,16 +25,17 @@ func (u *UserDB) Create(ctx context.Context, user *entity.User) error { func (u *UserDB) GetByID(ctx context.Context, id uuid.UUID) (entity.User, error) { - user, err := gorm.G[entity.User](u.db).Where("id = ?", id).First(ctx) + user, err := gorm.G[entity.User](u.db).Preload("Profile", func(db gorm.PreloadBuilder) error { return nil }).Preload("OauthProviders", func(db gorm.PreloadBuilder) error { return nil }).Preload("Passkeys", func(db gorm.PreloadBuilder) error { return nil }).Where("id = ?", id).First(ctx) return user, err } func (u *UserDB) GetByUsername(ctx context.Context, username string) (entity.User, error) { - user, err := gorm.G[entity.User](u.db).Where("username = ?", username).First(ctx) + user, err := gorm.G[entity.User](u.db).Preload("Profile", func(db gorm.PreloadBuilder) error { return nil }).Preload("OauthProviders", func(db gorm.PreloadBuilder) error { return nil }).Preload("Passkeys", func(db gorm.PreloadBuilder) error { return nil }).Where("username = ?", username).First(ctx) return user, err } + func (u *UserDB) UpdateUsername(ctx context.Context, id uuid.UUID, username string) error { _, err := gorm.G[entity.User](u.db).Where("id = ?", id).Update(ctx, "username", username) diff --git a/services/auth/internal/usecase/contracts.go b/services/auth/internal/usecase/contracts.go index 80d0031..2abf1c9 100644 --- a/services/auth/internal/usecase/contracts.go +++ b/services/auth/internal/usecase/contracts.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" @@ -41,6 +41,9 @@ type ( // Get provider by providerID GetByProviderID(ctx context.Context, providerID string) (entity.OauthProvider, error) + // List providers for a user + ListProviders(ctx context.Context, userID uuid.UUID) ([]entity.OauthProvider, error) + //Update OauthProvider object. Returns EUNAUTHORIZED if current user is not // the owner of provider that is being updated. Returns ENOTFOUND if provider does not exist. UpdateProviderID(ctx context.Context, id uuid.UUID, providerID string) @@ -68,5 +71,7 @@ type ( FinishRegistration(ctx context.Context, user entity.User, session webauthn.SessionData, response *http.Request) error BeginLogin(ctx context.Context, username string) (*protocol.CredentialAssertion, *webauthn.SessionData, error) FinishLogin(ctx context.Context, session webauthn.SessionData, response *http.Request) (entity.User, error) + ListPasskeys(ctx context.Context, userID uuid.UUID) ([]entity.Passkey, error) + DeletePasskey(ctx context.Context, id uuid.UUID) error } ) diff --git a/services/auth/internal/usecase/jwtmanager/jwtmanager.go b/services/auth/internal/usecase/jwtmanager/jwtmanager.go index 3fb7910..10e5f85 100644 --- a/services/auth/internal/usecase/jwtmanager/jwtmanager.go +++ b/services/auth/internal/usecase/jwtmanager/jwtmanager.go @@ -2,7 +2,7 @@ package jwtmanager import ( "errors" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "time" "github.com/golang-jwt/jwt/v5" diff --git a/services/auth/internal/usecase/oauthmanager/oauthmanager.go b/services/auth/internal/usecase/oauthmanager/oauthmanager.go index f175ffa..82d2859 100644 --- a/services/auth/internal/usecase/oauthmanager/oauthmanager.go +++ b/services/auth/internal/usecase/oauthmanager/oauthmanager.go @@ -3,8 +3,8 @@ package oauthmanager import ( "context" "errors" - "fitfeed/auth/internal/entity" - "fitfeed/auth/internal/repo" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/repo" "log/slog" "github.com/google/uuid" @@ -42,6 +42,15 @@ func (u *UseCase) GetByProviderID(ctx context.Context, providerID string) (entit return provider, nil } +func (u *UseCase) ListProviders(ctx context.Context, userID uuid.UUID) ([]entity.OauthProvider, error) { + providers, err := u.db.GetByUserID(ctx, userID) + if err != nil { + u.logger.Error("failed to list providers", "error", err, "user_id", userID) + return nil, entity.EINTERNAL + } + return providers, nil +} + // Update OauthProvider object. Returns EUNAUTHORIZED if current user is not // the owner of provider that is being updated. Returns ENOTFOUND if provider does not exist. func (u *UseCase) UpdateProviderID(ctx context.Context, id uuid.UUID, providerID string) { diff --git a/services/auth/internal/usecase/passkeymanager/passkeymanager.go b/services/auth/internal/usecase/passkeymanager/passkeymanager.go index 33d3be4..11726d9 100644 --- a/services/auth/internal/usecase/passkeymanager/passkeymanager.go +++ b/services/auth/internal/usecase/passkeymanager/passkeymanager.go @@ -2,23 +2,25 @@ package passkeymanager import ( "context" - "fitfeed/auth/internal/entity" - "fitfeed/auth/internal/repo" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/repo" + "github.com/Linux-oiD/fitfeed/services/auth/internal/usecase" "log/slog" "net/http" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" ) type UseCase struct { w *webauthn.WebAuthn db repo.PasskeyDB - u repo.UserDB + u usecase.UserManager logger *slog.Logger } -func New(w *webauthn.WebAuthn, db repo.PasskeyDB, u repo.UserDB, logger *slog.Logger) *UseCase { +func New(w *webauthn.WebAuthn, db repo.PasskeyDB, u usecase.UserManager, logger *slog.Logger) *UseCase { return &UseCase{w: w, db: db, u: u, logger: logger} } @@ -45,6 +47,8 @@ func (u *UseCase) FinishRegistration(ctx context.Context, user entity.User, sess AttestationType: credential.AttestationType, AAGUID: credential.Authenticator.AAGUID, SignCount: credential.Authenticator.SignCount, + BackupEligible: credential.Flags.BackupEligible, + BackupState: credential.Flags.BackupState, } err = u.db.Create(ctx, pk) @@ -62,6 +66,14 @@ func (u *UseCase) BeginLogin(ctx context.Context, username string) (*protocol.Cr return nil, nil, entity.ENOTFOUND } + // Load passkeys for this user from auth service DB + pks, err := u.db.GetByUserID(ctx, user.ID) + if err != nil { + u.logger.Error("failed to load passkeys for login", "error", err, "user_id", user.ID) + return nil, nil, entity.EINTERNAL + } + user.Passkeys = pks + assertion, sessionData, err := u.w.BeginLogin(user) if err != nil { u.logger.Error("failed to begin passkey login", "error", err, "username", username) @@ -71,16 +83,20 @@ func (u *UseCase) BeginLogin(ctx context.Context, username string) (*protocol.Cr } func (u *UseCase) FinishLogin(ctx context.Context, session webauthn.SessionData, response *http.Request) (entity.User, error) { - // WebAuthn FinishLogin needs the user, but we don't know who it is yet - // until we parse the response or we use the username from sessionData. - // Actually, sessionData has UserID. - + // username is stored in session.UserID username := string(session.UserID) user, err := u.u.GetByUsername(ctx, username) if err != nil { return entity.User{}, entity.ENOTFOUND } + // Load passkeys + pks, err := u.db.GetByUserID(ctx, user.ID) + if err != nil { + return entity.User{}, entity.EINTERNAL + } + user.Passkeys = pks + credential, err := u.w.FinishLogin(user, session, response) if err != nil { u.logger.Error("failed to finish passkey login", "error", err, "username", username) @@ -95,3 +111,21 @@ func (u *UseCase) FinishLogin(ctx context.Context, session webauthn.SessionData, return user, nil } + +func (u *UseCase) ListPasskeys(ctx context.Context, userID uuid.UUID) ([]entity.Passkey, error) { + pks, err := u.db.GetByUserID(ctx, userID) + if err != nil { + u.logger.Error("failed to list passkeys", "error", err, "user_id", userID) + return nil, entity.EINTERNAL + } + return pks, nil +} + +func (u *UseCase) DeletePasskey(ctx context.Context, id uuid.UUID) error { + err := u.db.Delete(ctx, id) + if err != nil { + u.logger.Error("failed to delete passkey", "error", err, "id", id) + return entity.EINTERNAL + } + return nil +} diff --git a/services/auth/internal/usecase/profilemanager/profilemanager.go b/services/auth/internal/usecase/profilemanager/profilemanager.go index 6381bd0..a9228dc 100644 --- a/services/auth/internal/usecase/profilemanager/profilemanager.go +++ b/services/auth/internal/usecase/profilemanager/profilemanager.go @@ -3,8 +3,8 @@ package profilemanager import ( "context" "errors" - "fitfeed/auth/internal/entity" - "fitfeed/auth/internal/repo" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/repo" "log/slog" "github.com/google/uuid" diff --git a/services/auth/internal/usecase/usermanager/usermanager.go b/services/auth/internal/usecase/usermanager/usermanager.go index 6d52189..1a9738b 100644 --- a/services/auth/internal/usecase/usermanager/usermanager.go +++ b/services/auth/internal/usecase/usermanager/usermanager.go @@ -2,50 +2,61 @@ package usermanager import ( "context" - "errors" - "fitfeed/auth/internal/entity" - "fitfeed/auth/internal/repo" + "github.com/Linux-oiD/fitfeed/services/auth/internal/bus" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/repo" + "github.com/Linux-oiD/fitfeed/services/auth/pkg/amqp" "log/slog" "github.com/google/uuid" - "gorm.io/gorm" ) type UserManager struct { - db repo.UserDB - profileDB repo.ProfileDB - logger *slog.Logger + db repo.UserDB + amqp amqp.ClientInterface + logger *slog.Logger } -func New(db repo.UserDB, profileDB repo.ProfileDB, logger *slog.Logger) *UserManager { - return &UserManager{db: db, profileDB: profileDB, logger: logger} +func New(db repo.UserDB, amqp amqp.ClientInterface, logger *slog.Logger) *UserManager { + return &UserManager{db: db, amqp: amqp, logger: logger} } func (u *UserManager) CheckUsername(ctx context.Context, username string) error { - _, err := u.db.GetByUsername(ctx, username) - if err == nil { - return entity.ENOTAVAILABLE + // Use AMQP RPC to check if user exists in API service + req := bus.UserLookupRequest{Username: username} + var resp bus.UserLookupResponse + err := u.amqp.Call(ctx, "user.lookup", req, &resp) + if err != nil { + u.logger.Error("failed to call user.lookup", "error", err) + return entity.EINTERNAL } - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil + + if resp.Exists { + return entity.ENOTAVAILABLE } - u.logger.Error("failed to check username", "error", err, "username", username) - return entity.EINTERNAL + return nil } func (u *UserManager) RegisterUser(ctx context.Context, user *entity.User) error { - err := u.db.Create(ctx, user) - if err != nil { - u.logger.Error("failed to register user", "error", err, "username", user.Username) - return entity.EINTERNAL + // Auth service doesn't manage users table anymore in this theory, + // but it needs a UserID to link OAuth/Passkeys. + if user.ID == uuid.Nil { + user.ID = uuid.New() + } + + // Publish UserCreated event + event := bus.UserCreateEvent{ + UserID: user.ID, + Username: user.Username, + Email: user.Profile.Email, + FirstName: user.Profile.FirstName, + LastName: user.Profile.LastName, + AvatarURL: user.Profile.AvatarURL, } - // Also create profile - user.Profile.UserID = user.ID - err = u.profileDB.Create(ctx, user.Profile) + err := u.amqp.Publish(ctx, "", "user.created", event) if err != nil { - u.logger.Error("failed to create profile for user", "error", err, "username", user.Username) - // NOTE: In a real system we should use a transaction here. + u.logger.Error("failed to publish user.created event", "error", err) return entity.EINTERNAL } @@ -53,31 +64,29 @@ func (u *UserManager) RegisterUser(ctx context.Context, user *entity.User) error } func (u *UserManager) GetByUsername(ctx context.Context, username string) (entity.User, error) { - user, err := u.db.GetByUsername(ctx, username) + // We still need to know the UserID in auth service to find THEIR passkeys/oauth. + + req := bus.UserLookupRequest{Username: username} + var resp bus.UserLookupResponse + err := u.amqp.Call(ctx, "user.lookup", req, &resp) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return entity.User{}, entity.ENOTFOUND - } - u.logger.Error("failed to get user by username", "error", err, "username", username) return entity.User{}, entity.EINTERNAL } - return user, nil + + if !resp.Exists { + return entity.User{}, entity.ENOTFOUND + } + + return entity.User{ + Base: entity.Base{ID: resp.UserID}, + Username: username, + }, nil } func (u *UserManager) UpdateUsername(ctx context.Context, id uuid.UUID, username string) (entity.User, error) { - err := u.db.UpdateUsername(ctx, id, username) - if err != nil { - u.logger.Error("failed to update username", "error", err, "id", id, "username", username) - return entity.User{}, entity.EINTERNAL - } - return u.db.GetByID(ctx, id) + panic("moved to api service") } func (u *UserManager) DeleteUser(ctx context.Context, id uuid.UUID) error { - err := u.db.Delete(ctx, id) - if err != nil { - u.logger.Error("failed to delete user", "error", err, "id", id) - return entity.EINTERNAL - } - return nil + panic("moved to api service") } diff --git a/services/auth/internal/usecase/usermanager/usermanager_test.go b/services/auth/internal/usecase/usermanager/usermanager_test.go index a9a91eb..9f4c6fd 100644 --- a/services/auth/internal/usecase/usermanager/usermanager_test.go +++ b/services/auth/internal/usecase/usermanager/usermanager_test.go @@ -2,7 +2,8 @@ package usermanager import ( "context" - "fitfeed/auth/internal/entity" + "github.com/Linux-oiD/fitfeed/services/auth/internal/bus" + "github.com/Linux-oiD/fitfeed/services/auth/internal/entity" "log/slog" "os" "testing" @@ -10,15 +11,34 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "gorm.io/gorm" ) -// Mocks +// Mock AMQP Client +type mockAMQPClient struct { + mock.Mock +} + +func (m *mockAMQPClient) Publish(ctx context.Context, exchange, routingKey string, body interface{}) error { + args := m.Called(ctx, exchange, routingKey, body) + return args.Error(0) +} + +func (m *mockAMQPClient) Call(ctx context.Context, queueName string, request interface{}, response interface{}) error { + args := m.Called(ctx, queueName, request, response) + if args.Get(0) != nil { + res := args.Get(0).(bus.UserLookupResponse) + *(response.(*bus.UserLookupResponse)) = res + } + return args.Error(1) +} + +func (m *mockAMQPClient) Close() {} + type mockUserDB struct { mock.Mock } -func (m *mockUserDB) Create(ctx context.Context, u entity.User) error { +func (m *mockUserDB) Create(ctx context.Context, u *entity.User) error { args := m.Called(ctx, u) return args.Error(0) } @@ -43,44 +63,20 @@ func (m *mockUserDB) Delete(ctx context.Context, id uuid.UUID) error { return args.Error(0) } -type mockProfileDB struct { - mock.Mock -} - -func (m *mockProfileDB) Create(ctx context.Context, p entity.Profile) error { - args := m.Called(ctx, p) - return args.Error(0) -} - -func (m *mockProfileDB) GetByID(ctx context.Context, id uuid.UUID) (entity.Profile, error) { - args := m.Called(ctx, id) - return args.Get(0).(entity.Profile), args.Error(1) -} - -func (m *mockProfileDB) GetByEmail(ctx context.Context, email string) (entity.Profile, error) { - args := m.Called(ctx, email) - return args.Get(0).(entity.Profile), args.Error(1) -} - -func (m *mockProfileDB) Update(ctx context.Context, id uuid.UUID, p entity.Profile) error { - args := m.Called(ctx, id, p) - return args.Error(0) -} - func TestCheckUsername(t *testing.T) { udb := new(mockUserDB) - pdb := new(mockProfileDB) + mamqp := new(mockAMQPClient) logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - um := New(udb, pdb, logger) + um := New(udb, mamqp, logger) t.Run("available", func(t *testing.T) { - udb.On("GetByUsername", mock.Anything, "newuser").Return(entity.User{}, gorm.ErrRecordNotFound).Once() + mamqp.On("Call", mock.Anything, "user.lookup", mock.Anything, mock.Anything).Return(bus.UserLookupResponse{Exists: false}, nil).Once() err := um.CheckUsername(context.Background(), "newuser") assert.NoError(t, err) }) t.Run("taken", func(t *testing.T) { - udb.On("GetByUsername", mock.Anything, "taken").Return(entity.User{Username: "taken"}, nil).Once() + mamqp.On("Call", mock.Anything, "user.lookup", mock.Anything, mock.Anything).Return(bus.UserLookupResponse{Exists: true}, nil).Once() err := um.CheckUsername(context.Background(), "taken") assert.Equal(t, entity.ENOTAVAILABLE, err) }) @@ -88,14 +84,13 @@ func TestCheckUsername(t *testing.T) { func TestRegisterUser(t *testing.T) { udb := new(mockUserDB) - pdb := new(mockProfileDB) + mamqp := new(mockAMQPClient) logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - um := New(udb, pdb, logger) + um := New(udb, mamqp, logger) t.Run("success", func(t *testing.T) { - user := entity.User{Username: "test"} - udb.On("Create", mock.Anything, user).Return(nil).Once() - pdb.On("Create", mock.Anything, mock.Anything).Return(nil).Once() + user := &entity.User{Username: "test"} + mamqp.On("Publish", mock.Anything, "", "user.created", mock.Anything).Return(nil).Once() err := um.RegisterUser(context.Background(), user) assert.NoError(t, err) diff --git a/services/auth/tmp/build-errors.log b/services/auth/tmp/build-errors.log deleted file mode 100644 index c38c619..0000000 --- a/services/auth/tmp/build-errors.log +++ /dev/null @@ -1 +0,0 @@ -exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/services/dbm/README.md b/services/dbm/README.md index 8614f46..80b014d 100644 --- a/services/dbm/README.md +++ b/services/dbm/README.md @@ -1,3 +1,56 @@ -# FitFeed dbm service +# FitFeed Database Migration Service (`dbm`) -This service handles DB migrations. +`dbm` is a self-contained microservice responsible for schema evolution and database migrations across FitFeed services using `pressly/goose/v3`. + +## Architecture & Principles + +- **Static Embedded SQL Files**: All migration scripts (`.sql`) reside in `internal/migrations/` and are embedded directly into the compiled Go binary via Go's `embed.FS`. +- **Deterministic & Idempotent**: Migrations run strictly from static SQL DDL statements rather than compiled application Go models, guaranteeing schema idempotency regardless of future application code refactoring. +- **Goose Tracking Table**: Database migration state is tracked in the `goose_db_version` table. + +## Commands & Usage + +Run migrations using the project Makefile from the repository root: + +```bash +# Run all pending forward migrations +make migrate-up + +# Rollback the last applied migration step +make migrate-down +``` + +Or execute `dbm` directly: + +```bash +# Run up migrations +go run cmd/dbm/main.go up + +# Rollback one migration step +go run cmd/dbm/main.go down + +# Check migration status +go run cmd/dbm/main.go status + +# View current database schema version +go run cmd/dbm/main.go version +``` + +## Adding a New Migration + +1. Create a new `.sql` file in `internal/migrations/` using 5-digit sequential numbering (e.g., `00003_add_new_feature_table.sql`). +2. Add Goose annotations for forward (`-- +goose Up`) and reverse (`-- +goose Down`) SQL operations: + +```sql +-- +goose Up +CREATE TABLE IF NOT EXISTS example_table ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- +goose Down +DROP TABLE IF EXISTS example_table; +``` + +3. Test your migration with `make migrate-up` and `make migrate-down`. diff --git a/services/dbm/cmd/dbm/main.go b/services/dbm/cmd/dbm/main.go index a1df179..3d1c766 100644 --- a/services/dbm/cmd/dbm/main.go +++ b/services/dbm/cmd/dbm/main.go @@ -6,15 +6,14 @@ import ( "log/slog" "os" - "fitfeed/dbm/internal/config" - "fitfeed/dbm/internal/db" - _ "fitfeed/dbm/internal/migrations" + "github.com/Linux-oiD/fitfeed/services/dbm/internal/config" + "github.com/Linux-oiD/fitfeed/services/dbm/internal/db" + "github.com/Linux-oiD/fitfeed/services/dbm/internal/migrations" "github.com/pressly/goose/v3" ) func main() { - conf := config.Load() var handler slog.Handler if conf.IsProd { @@ -47,6 +46,9 @@ func main() { os.Exit(1) } + // Register embedded SQL migrations filesystem + goose.SetBaseFS(migrations.EmbedFS) + ctx := context.WithValue(context.Background(), "driver", conf.DB.Driver) command := args[0] diff --git a/services/dbm/go.mod b/services/dbm/go.mod index 8598214..a72df98 100644 --- a/services/dbm/go.mod +++ b/services/dbm/go.mod @@ -1,4 +1,4 @@ -module fitfeed/dbm +module github.com/Linux-oiD/fitfeed/services/dbm go 1.24.5 diff --git a/services/dbm/internal/db/db.go b/services/dbm/internal/db/db.go index 9f161e5..55bc74b 100644 --- a/services/dbm/internal/db/db.go +++ b/services/dbm/internal/db/db.go @@ -2,7 +2,7 @@ package db import ( "database/sql" - "fitfeed/dbm/internal/config" + "github.com/Linux-oiD/fitfeed/services/dbm/internal/config" "fmt" "gorm.io/driver/mysql" @@ -10,6 +10,7 @@ import ( "gorm.io/gorm" ) +// Connect initializes and returns a native *sql.DB database handle based on AppConfig. func Connect(conf *config.AppConfig) (*sql.DB, error) { var dialector gorm.Dialector @@ -34,16 +35,3 @@ func Connect(conf *config.AppConfig) (*sql.DB, error) { } return gdb.DB() } - -func GetGormTx(tx *sql.Tx, driver string) (*gorm.DB, error) { - var dialector gorm.Dialector - switch driver { - case "postgres": - dialector = postgres.New(postgres.Config{Conn: tx}) - case "mysql": - dialector = mysql.New(mysql.Config{Conn: tx}) - default: - return nil, fmt.Errorf("unsupported driver: %s", driver) - } - return gorm.Open(dialector, &gorm.Config{}) -} diff --git a/services/dbm/internal/migrations/00001_create_initial_tables.go b/services/dbm/internal/migrations/00001_create_initial_tables.go deleted file mode 100644 index f6a7f55..0000000 --- a/services/dbm/internal/migrations/00001_create_initial_tables.go +++ /dev/null @@ -1,34 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "fitfeed/dbm/internal/db" - "fitfeed/dbm/internal/models" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upCreateInitialTables, downCreateInitialTables) -} - -func upCreateInitialTables(ctx context.Context, tx *sql.Tx) error { - driver := ctx.Value("driver").(string) - gdb, err := db.GetGormTx(tx, driver) - if err != nil { - return err - } - - return gdb.Migrator().CreateTable( - &models.User{}, - &models.Profile{}, - &models.OauthProvider{}, - ) -} - -func downCreateInitialTables(ctx context.Context, tx *sql.Tx) error { - driver := ctx.Value("driver").(string) - gdb, _ := db.GetGormTx(tx, driver) - return gdb.Migrator().DropTable(models.OauthProvider{}, &models.Profile{}, &models.User{}) -} diff --git a/services/dbm/internal/migrations/00001_create_initial_tables.sql b/services/dbm/internal/migrations/00001_create_initial_tables.sql new file mode 100644 index 0000000..450777d --- /dev/null +++ b/services/dbm/internal/migrations/00001_create_initial_tables.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- SQL migration for creating initial tables: users, profiles, oauth_providers + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + first_name VARCHAR(255), + last_name VARCHAR(255), + avatar_url VARCHAR(255), + email VARCHAR(255) UNIQUE, + date_of_birth TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_profiles_deleted_at ON profiles(deleted_at); + +CREATE TABLE IF NOT EXISTS oauth_providers ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(31), + provider_id VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_oauth_providers_provider_id ON oauth_providers(provider_id); +CREATE INDEX IF NOT EXISTS idx_oauth_providers_deleted_at ON oauth_providers(deleted_at); + +-- +goose Down +DROP TABLE IF EXISTS oauth_providers; +DROP TABLE IF EXISTS profiles; +DROP TABLE IF EXISTS users; diff --git a/services/dbm/internal/migrations/00002_add_passkeys_table.go b/services/dbm/internal/migrations/00002_add_passkeys_table.go deleted file mode 100644 index 2ad6030..0000000 --- a/services/dbm/internal/migrations/00002_add_passkeys_table.go +++ /dev/null @@ -1,29 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "fitfeed/dbm/internal/db" - "fitfeed/dbm/internal/models" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddPasskeysTable, downAddPasskeysTable) -} - -func upAddPasskeysTable(ctx context.Context, tx *sql.Tx) error { - driver := ctx.Value("driver").(string) - gdb, err := db.GetGormTx(tx, driver) - if err != nil { - return err - } - return gdb.Migrator().CreateTable(&models.Passkey{}) -} - -func downAddPasskeysTable(ctx context.Context, tx *sql.Tx) error { - driver := ctx.Value("driver").(string) - gdb, _ := db.GetGormTx(tx, driver) - return gdb.Migrator().DropTable(&models.Passkey{}) -} diff --git a/services/dbm/internal/migrations/00002_add_passkeys_table.sql b/services/dbm/internal/migrations/00002_add_passkeys_table.sql new file mode 100644 index 0000000..504ee0f --- /dev/null +++ b/services/dbm/internal/migrations/00002_add_passkeys_table.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- SQL migration for creating passkeys table + +CREATE TABLE IF NOT EXISTS passkeys ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + credential_id BYTEA, + public_key BYTEA, + attestation_type VARCHAR(255), + aaguid BYTEA, + sign_count BIGINT DEFAULT 0, + backup_eligible BOOLEAN DEFAULT FALSE, + backup_state BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id); +CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id); +CREATE INDEX IF NOT EXISTS idx_passkeys_deleted_at ON passkeys(deleted_at); + +-- +goose Down +DROP TABLE IF EXISTS passkeys; diff --git a/services/dbm/internal/migrations/00003_add_activities_likes_and_comments.sql b/services/dbm/internal/migrations/00003_add_activities_likes_and_comments.sql new file mode 100644 index 0000000..da71fb4 --- /dev/null +++ b/services/dbm/internal/migrations/00003_add_activities_likes_and_comments.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- SQL migration for activities, activity_likes, and activity_comments tables + +CREATE TABLE IF NOT EXISTS activities ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + activity_type VARCHAR(50) NOT NULL, + distance DOUBLE PRECISION DEFAULT 0, + duration BIGINT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_activities_user_id ON activities(user_id); +CREATE INDEX IF NOT EXISTS idx_activities_deleted_at ON activities(deleted_at); + +CREATE TABLE IF NOT EXISTS activity_likes ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_user_activity_like UNIQUE (user_id, activity_id) +); + +CREATE INDEX IF NOT EXISTS idx_activity_likes_activity_id ON activity_likes(activity_id); + +CREATE TABLE IF NOT EXISTS activity_comments ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_activity_comments_activity_id ON activity_comments(activity_id); +CREATE INDEX IF NOT EXISTS idx_activity_comments_deleted_at ON activity_comments(deleted_at); + +-- +goose Down +DROP TABLE IF EXISTS activity_comments; +DROP TABLE IF EXISTS activity_likes; +DROP TABLE IF EXISTS activities; diff --git a/services/dbm/internal/migrations/embed.go b/services/dbm/internal/migrations/embed.go new file mode 100644 index 0000000..cd36c39 --- /dev/null +++ b/services/dbm/internal/migrations/embed.go @@ -0,0 +1,7 @@ +package migrations + +import "embed" + +// EmbedFS embeds all .sql files in this directory. +//go:embed *.sql +var EmbedFS embed.FS diff --git a/services/dbm/internal/models/models.go b/services/dbm/internal/models/models.go index 0ff3802..287b665 100644 --- a/services/dbm/internal/models/models.go +++ b/services/dbm/internal/models/models.go @@ -34,24 +34,28 @@ type User struct { type Passkey struct { Base - UserID uuid.UUID `gorm:"type:uuid;index" json:"user_id"` - CredentialID []byte `gorm:"type:bytea;index" json:"credential_id"` - PublicKey []byte `gorm:"type:bytea" json:"public_key"` - AttestationType string `gorm:"size:255" json:"attestation_type"` - AAGUID []byte `gorm:"type:bytea" json:"aaguid"` - SignCount uint32 `json:"sign_count"` + UserID uuid.UUID `gorm:"type:uuid;index" json:"user_id"` + CredentialID []byte `gorm:"type:bytea;index" json:"credential_id"` + PublicKey []byte `gorm:"type:bytea" json:"public_key"` + AttestationType string `gorm:"size:255" json:"attestation_type"` + AAGUID []byte `gorm:"type:bytea" json:"aaguid"` + SignCount uint32 `json:"sign_count"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` } // Profile is the model for the profile table. type Profile struct { Base - FirstName string `gorm:"size:255" json:"first_name"` - LastName string `gorm:"size:255" json:"last_name"` - AvatarURL string `gorm:"size:255" json:"avatar_url"` - Email string `gorm:"index;unique" json:"email"` - UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` + FirstName string `gorm:"size:255" json:"first_name"` + LastName string `gorm:"size:255" json:"last_name"` + AvatarURL string `gorm:"size:255" json:"avatar_url"` + Email string `gorm:"uniqueIndex" json:"email"` + DateOfBirth *time.Time `json:"date_of_birth"` + UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` } + type OauthProvider struct { Base UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` diff --git a/services/web/bun.lock b/services/web/bun.lock index e387d17..3236ed7 100644 --- a/services/web/bun.lock +++ b/services/web/bun.lock @@ -9,8 +9,10 @@ "@ant-design/v5-patch-for-react-19": "^1.0.3", "@github/webauthn-json": "^2.1.1", "antd": "^5.27.1", + "dayjs": "^1.11.20", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-router-dom": "^7.14.0", }, "devDependencies": { "@eslint/js": "^9.32.0", @@ -320,13 +322,15 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "copy-to-clipboard": ["copy-to-clipboard@3.3.3", "", { "dependencies": { "toggle-selection": "^1.0.6" } }, "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - "dayjs": ["dayjs@1.11.18", "", {}, "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], @@ -548,6 +552,10 @@ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="], + + "react-router-dom": ["react-router-dom@7.14.0", "", { "dependencies": { "react-router": "7.14.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ=="], + "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -564,6 +572,8 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -620,6 +630,8 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "antd/dayjs": ["dayjs@1.11.18", "", {}, "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/services/web/package.json b/services/web/package.json index 6cd1c61..b381c64 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -14,8 +14,10 @@ "@ant-design/v5-patch-for-react-19": "^1.0.3", "@github/webauthn-json": "^2.1.1", "antd": "^5.27.1", + "dayjs": "^1.11.20", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.14.0" }, "devDependencies": { "@eslint/js": "^9.32.0", diff --git a/services/web/src/App.tsx b/services/web/src/App.tsx index f5dcd7d..7f3d29c 100644 --- a/services/web/src/App.tsx +++ b/services/web/src/App.tsx @@ -1,9 +1,12 @@ import React from 'react'; import '@ant-design/v5-patch-for-react-19'; import { ConfigProvider } from 'antd'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { AuthProvider } from './context/AuthContext'; import MainLayout from './components/layout/MainLayout'; import Home from './pages/Home'; +import ProfilePage from './pages/Profile'; +import SettingsPage from './pages/Settings'; import './App.css'; const App: React.FC = () => { @@ -16,9 +19,15 @@ const App: React.FC = () => { }} > - - - + + + + } /> + } /> + } /> + + + ); diff --git a/services/web/src/components/ActivityCommentSection.tsx b/services/web/src/components/ActivityCommentSection.tsx new file mode 100644 index 0000000..1783e8e --- /dev/null +++ b/services/web/src/components/ActivityCommentSection.tsx @@ -0,0 +1,208 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { List, Avatar, Input, Button, Space, Typography, Popconfirm, message, Spin } from 'antd'; +import { UserOutlined, SendOutlined, DeleteOutlined, MessageOutlined } from '@ant-design/icons'; +import { socialApi } from '../services/socialApi'; +import { useAuth } from '../context/AuthContext'; +import type { ActivityComment } from '../types'; + +const { Text } = Typography; +const { TextArea } = Input; + +interface ActivityCommentSectionProps { + activityId: string; + onCommentCountChange?: (newCount: number) => void; +} + +export const ActivityCommentSection: React.FC = ({ + activityId, + onCommentCountChange, +}) => { + const { user } = useAuth(); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [inputContent, setInputContent] = useState(''); + + const fetchComments = useCallback(async () => { + setLoading(true); + try { + const res = await socialApi.getComments(activityId); + setComments(res.comments || []); + if (onCommentCountChange) { + onCommentCountChange(res.comments ? res.comments.length : 0); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to load comments'; + message.error(msg); + } finally { + setLoading(false); + } + }, [activityId, onCommentCountChange]); + + useEffect(() => { + fetchComments(); + }, [fetchComments]); + + const handleSubmit = async () => { + const trimmed = inputContent.trim(); + if (!trimmed) { + message.warning('Comment cannot be empty or contain only whitespace'); + return; + } + + setSubmitting(true); + try { + const created = await socialApi.createComment(activityId, trimmed); + // Enrich author name if omitted in creation response + if (!created.author_name && user) { + created.author_name = user.profile.first_name + ? `${user.profile.first_name} ${user.profile.last_name || ''}`.trim() + : user.username; + created.author_avatar = user.profile.avatar_url; + } + const updated = [...comments, created]; + setComments(updated); + setInputContent(''); + if (onCommentCountChange) { + onCommentCountChange(updated.length); + } + message.success('Comment added'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to submit comment'; + message.error(msg); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (commentId: string) => { + try { + await socialApi.deleteComment(commentId); + const updated = comments.filter((c) => c.id !== commentId); + setComments(updated); + if (onCommentCountChange) { + onCommentCountChange(updated.length); + } + message.success('Comment removed'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to delete comment'; + message.error(msg); + } + }; + + const formatDate = (isoString: string) => { + try { + const date = new Date(isoString); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return isoString; + } + }; + + return ( +
+
+ + Comments ({comments.length}) +
+ + {loading ? ( +
+ +
+ ) : ( + No comments yet. Be the first to comment! }} + renderItem={(item) => { + const isAuthor = user?.id === item.user_id; + return ( + handleDelete(item.id)} + okText="Delete" + cancelText="Cancel" + okButtonProps={{ danger: true, size: 'small' }} + > +