Compare commits
9 Commits
1aedc2b69a
...
v1.7.3
| Author | SHA1 | Date | |
|---|---|---|---|
| a07d033e93 | |||
| 67fc508384 | |||
| 92fe617f73 | |||
| 94c5c30d09 | |||
| 2b059ec334 | |||
| 77cfb709d8 | |||
| aae68ba20e | |||
| 8883cdcb86 | |||
| aa8f7af55d |
@@ -4,6 +4,7 @@ data/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
apocapoc-api
|
||||
/api
|
||||
dist/
|
||||
bin/
|
||||
.internal-notes/
|
||||
|
||||
@@ -20,7 +20,7 @@ REST API for habit tracking built with Go. Self-hosted alternative for developer
|
||||
|
||||
- Multiple habit types: Boolean, Counter, Value
|
||||
- Flexible scheduling: Daily, Weekly, Monthly
|
||||
- Statistics: Streaks, completion rates, progress tracking
|
||||
- Statistics: Streaks and completions tracking
|
||||
- JWT authentication, rate limiting, optional email verification
|
||||
- Registration modes: Open or closed
|
||||
- SQLite database (single file)
|
||||
|
||||
+35
-6
@@ -1,15 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/backup"
|
||||
@@ -90,7 +95,7 @@ func main() {
|
||||
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
|
||||
passwordHasher := crypto.NewBcryptHasher()
|
||||
|
||||
var emailService *email.SMTPService
|
||||
var emailService services.EmailService = &services.NoOpEmailService{}
|
||||
if cfg.SMTPHost != "" {
|
||||
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
|
||||
if err != nil {
|
||||
@@ -141,6 +146,8 @@ func main() {
|
||||
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
||||
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
|
||||
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
applySyncBatchHandler := commands.NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
|
||||
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
|
||||
@@ -148,15 +155,37 @@ func main() {
|
||||
healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService)
|
||||
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
|
||||
exportHandlers := httpInfra.NewExportHandlers(exportUserDataHandler, translator)
|
||||
syncHandlers := httpInfra.NewSyncHandlers(getSyncChangesHandler, applySyncBatchHandler, translator)
|
||||
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
|
||||
|
||||
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||
logger.Info().Str("address", addr).Msg("Server starting")
|
||||
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
logger.Fatal().Err(err).Msg("Server failed")
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
logger.Info().Str("address", addr).Msg("Server starting")
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal().Err(err).Msg("Server failed")
|
||||
}
|
||||
}()
|
||||
|
||||
<-shutdown
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Fatal().Err(err).Msg("Server forced to shutdown")
|
||||
}
|
||||
|
||||
logger.Info().Msg("Server stopped")
|
||||
}
|
||||
|
||||
func parseJWTExpiry(expiry string) (int, error) {
|
||||
|
||||
+850
-16
@@ -24,6 +24,67 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/auth/forgot-password": {
|
||||
"post": {
|
||||
"description": "Request a password reset email with a reset token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Request password reset",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ForgotPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reset email sent successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.",
|
||||
@@ -67,6 +128,12 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -185,7 +252,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"description": "Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.",
|
||||
"description": "Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -209,13 +276,19 @@ const docTemplate = `{
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Returns access token, refresh token, and user ID",
|
||||
"description": "Returns user ID and message about next steps",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.AuthResponse"
|
||||
"$ref": "#/definitions/http.RegisterResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input: email format, password requirements, or timezone",
|
||||
"description": "Invalid input: email format or password requirements",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ValidationErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Registration is closed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
@@ -235,6 +308,220 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/resend-verification": {
|
||||
"post": {
|
||||
"description": "Resend the email verification link to the user's email address",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Resend verification email",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResendVerificationRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Verification email sent",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/reset-password": {
|
||||
"post": {
|
||||
"description": "Reset user password using the reset token from email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Reset password",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Reset token and new password",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid token or password requirements not met",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/verify-email": {
|
||||
"post": {
|
||||
"description": "Verify user email address using the token sent via email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Verify email address",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Verification token",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.VerifyEmailRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Email verified successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or expired token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"export"
|
||||
],
|
||||
"summary": "Export user data",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Compressed JSON export",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/queries.ExportUserDataResult"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit exceeded",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/habits": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -242,7 +529,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all active habits for the authenticated user",
|
||||
"description": "Get all active habits for the authenticated user with optional pagination and filters",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -250,14 +537,49 @@ const docTemplate = `{
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get all user habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page number (default: 1)",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page size (default: 50, max: 100)",
|
||||
"name": "page_size",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by type (BOOLEAN, COUNTER, VALUE)",
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by frequency (DAILY, WEEKLY, MONTHLY)",
|
||||
"name": "frequency",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include archived habits (default: false)",
|
||||
"name": "archived",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search by name or description",
|
||||
"name": "search",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
"$ref": "#/definitions/http.GetUserHabitsResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
@@ -340,7 +662,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all habits scheduled for today for the authenticated user",
|
||||
"description": "Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -348,6 +670,15 @@ const docTemplate = `{
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get today's habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')",
|
||||
"name": "timezone",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -358,6 +689,12 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or missing timezone",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
@@ -835,7 +1172,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get statistics for a specific habit including streaks and completion rates",
|
||||
"description": "Get statistics for a specific habit including streaks and completions",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -885,6 +1222,167 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/batch": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Apply a batch of changes from the client for offline sync (Last-Write-Wins)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Apply sync batch",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Sync batch data",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncBatchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/changes": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all changes (habits and entries) since a given timestamp for offline sync",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Get sync changes",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)",
|
||||
"name": "since",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncChangesResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/me": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Delete user account",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account deleted successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -940,6 +1438,29 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.EntryChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -948,6 +1469,51 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ForgotPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.GetUserHabitsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/definitions/pagination.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitEntriesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -994,6 +1560,9 @@ const docTemplate = `{
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtp": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1048,15 +1617,157 @@ const docTemplate = `{
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.RegisterResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResendVerificationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResetPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncBatchRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncChangesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitEntryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/http.TodaysHabitEntryResponse"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1089,6 +1800,9 @@ const docTemplate = `{
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1141,12 +1855,132 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ValidationErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"field": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.VerifyEmailRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination.Response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportUserDataResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportEntryDTO"
|
||||
}
|
||||
},
|
||||
"exported_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.HabitStatsDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completion_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"completions_this_month": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
+850
-16
@@ -16,6 +16,67 @@
|
||||
},
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/auth/forgot-password": {
|
||||
"post": {
|
||||
"description": "Request a password reset email with a reset token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Request password reset",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ForgotPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reset email sent successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.",
|
||||
@@ -59,6 +120,12 @@
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -177,7 +244,7 @@
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"description": "Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.",
|
||||
"description": "Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -201,13 +268,19 @@
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Returns access token, refresh token, and user ID",
|
||||
"description": "Returns user ID and message about next steps",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.AuthResponse"
|
||||
"$ref": "#/definitions/http.RegisterResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input: email format, password requirements, or timezone",
|
||||
"description": "Invalid input: email format or password requirements",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ValidationErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Registration is closed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
@@ -227,6 +300,220 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/resend-verification": {
|
||||
"post": {
|
||||
"description": "Resend the email verification link to the user's email address",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Resend verification email",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResendVerificationRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Verification email sent",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/reset-password": {
|
||||
"post": {
|
||||
"description": "Reset user password using the reset token from email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Reset password",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Reset token and new password",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid token or password requirements not met",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/verify-email": {
|
||||
"post": {
|
||||
"description": "Verify user email address using the token sent via email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Verify email address",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Verification token",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.VerifyEmailRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Email verified successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or expired token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"export"
|
||||
],
|
||||
"summary": "Export user data",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Compressed JSON export",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/queries.ExportUserDataResult"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit exceeded",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/habits": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -234,7 +521,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all active habits for the authenticated user",
|
||||
"description": "Get all active habits for the authenticated user with optional pagination and filters",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -242,14 +529,49 @@
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get all user habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page number (default: 1)",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page size (default: 50, max: 100)",
|
||||
"name": "page_size",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by type (BOOLEAN, COUNTER, VALUE)",
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by frequency (DAILY, WEEKLY, MONTHLY)",
|
||||
"name": "frequency",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include archived habits (default: false)",
|
||||
"name": "archived",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search by name or description",
|
||||
"name": "search",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
"$ref": "#/definitions/http.GetUserHabitsResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
@@ -332,7 +654,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all habits scheduled for today for the authenticated user",
|
||||
"description": "Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -340,6 +662,15 @@
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get today's habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')",
|
||||
"name": "timezone",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -350,6 +681,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or missing timezone",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
@@ -827,7 +1164,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get statistics for a specific habit including streaks and completion rates",
|
||||
"description": "Get statistics for a specific habit including streaks and completions",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -877,6 +1214,167 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/batch": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Apply a batch of changes from the client for offline sync (Last-Write-Wins)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Apply sync batch",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Sync batch data",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncBatchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/changes": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all changes (habits and entries) since a given timestamp for offline sync",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Get sync changes",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)",
|
||||
"name": "since",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncChangesResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/me": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Delete user account",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account deleted successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -932,6 +1430,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.EntryChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -940,6 +1461,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ForgotPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.GetUserHabitsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/definitions/pagination.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitEntriesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -986,6 +1552,9 @@
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtp": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1040,15 +1609,157 @@
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.RegisterResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResendVerificationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResetPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncBatchRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncChangesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitEntryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/http.TodaysHabitEntryResponse"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1081,6 +1792,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1133,12 +1847,132 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ValidationErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"field": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.VerifyEmailRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination.Response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportUserDataResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportEntryDTO"
|
||||
}
|
||||
},
|
||||
"exported_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.HabitStatsDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completion_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"completions_this_month": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
+560
-16
@@ -34,11 +34,55 @@ definitions:
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
http.EntryChangesDTO:
|
||||
properties:
|
||||
created:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitEntryDTO'
|
||||
type: array
|
||||
deleted:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
updated:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitEntryDTO'
|
||||
type: array
|
||||
type: object
|
||||
http.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
type: object
|
||||
http.ForgotPasswordRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
type: object
|
||||
http.GetUserHabitsResponse:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/http.UserHabitResponse'
|
||||
type: array
|
||||
pagination:
|
||||
$ref: '#/definitions/pagination.Response'
|
||||
type: object
|
||||
http.HabitChangesDTO:
|
||||
properties:
|
||||
created:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitDTO'
|
||||
type: array
|
||||
deleted:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
updated:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitDTO'
|
||||
type: array
|
||||
type: object
|
||||
http.HabitEntriesResponse:
|
||||
properties:
|
||||
entries:
|
||||
@@ -69,6 +113,8 @@ definitions:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
smtp:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
uptime:
|
||||
@@ -104,11 +150,103 @@ definitions:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
timezone:
|
||||
type: object
|
||||
http.RegisterResponse:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
type: object
|
||||
http.ResendVerificationRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
type: object
|
||||
http.ResetPasswordRequest:
|
||||
properties:
|
||||
new_password:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
http.SyncBatchRequest:
|
||||
properties:
|
||||
entries:
|
||||
$ref: '#/definitions/http.EntryChangesDTO'
|
||||
habits:
|
||||
$ref: '#/definitions/http.HabitChangesDTO'
|
||||
type: object
|
||||
http.SyncChangesResponse:
|
||||
properties:
|
||||
entries:
|
||||
$ref: '#/definitions/http.EntryChangesDTO'
|
||||
habits:
|
||||
$ref: '#/definitions/http.HabitChangesDTO'
|
||||
type: object
|
||||
http.SyncHabitDTO:
|
||||
properties:
|
||||
archived_at:
|
||||
type: string
|
||||
carry_over:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
id:
|
||||
type: string
|
||||
is_negative:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
specific_days:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
target_value:
|
||||
type: number
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
updated_at:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
type: object
|
||||
http.SyncHabitEntryDTO:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
habit_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
scheduled_date:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
http.TodaysHabitEntryResponse:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
http.TodaysHabitResponse:
|
||||
properties:
|
||||
entry:
|
||||
$ref: '#/definitions/http.TodaysHabitEntryResponse'
|
||||
id:
|
||||
type: string
|
||||
is_carried_over:
|
||||
@@ -130,6 +268,8 @@ definitions:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
@@ -164,10 +304,88 @@ definitions:
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
http.ValidationErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
field:
|
||||
type: string
|
||||
type: object
|
||||
http.VerifyEmailRequest:
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
pagination.Response:
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
total_items:
|
||||
type: integer
|
||||
total_pages:
|
||||
type: integer
|
||||
type: object
|
||||
queries.ExportEntryDTO:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
habit_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
scheduled_date:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
queries.ExportHabitDTO:
|
||||
properties:
|
||||
archived_at:
|
||||
type: string
|
||||
carry_over:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
id:
|
||||
type: string
|
||||
is_negative:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
specific_days:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
target_value:
|
||||
type: number
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
queries.ExportUserDataResult:
|
||||
properties:
|
||||
entries:
|
||||
items:
|
||||
$ref: '#/definitions/queries.ExportEntryDTO'
|
||||
type: array
|
||||
exported_at:
|
||||
type: string
|
||||
habits:
|
||||
items:
|
||||
$ref: '#/definitions/queries.ExportHabitDTO'
|
||||
type: array
|
||||
type: object
|
||||
queries.HabitStatsDTO:
|
||||
properties:
|
||||
completion_rate:
|
||||
type: number
|
||||
completions_this_month:
|
||||
type: integer
|
||||
completions_this_week:
|
||||
@@ -215,6 +433,46 @@ info:
|
||||
termsOfService: http://swagger.io/terms/
|
||||
title: Apocapoc API
|
||||
paths:
|
||||
/auth/forgot-password:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Request a password reset email with a reset token
|
||||
parameters:
|
||||
- description: User email
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ForgotPasswordRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Reset email sent successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid email
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"403":
|
||||
description: Email not verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Request password reset
|
||||
tags:
|
||||
- auth
|
||||
/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
@@ -244,6 +502,10 @@ paths:
|
||||
description: Invalid email or password
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"403":
|
||||
description: Email not verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
@@ -333,9 +595,8 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a new user account and receive both access token and refresh
|
||||
token. Store both tokens securely - the refresh token is used to obtain new
|
||||
access tokens when they expire.
|
||||
description: Create a new user account. If email verification is enabled, you
|
||||
will receive a verification email. Otherwise, you can login immediately.
|
||||
parameters:
|
||||
- description: 'Registration data (password requires: min 8 chars, uppercase,
|
||||
lowercase, digit, special char)'
|
||||
@@ -348,11 +609,15 @@ paths:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Returns access token, refresh token, and user ID
|
||||
description: Returns user ID and message about next steps
|
||||
schema:
|
||||
$ref: '#/definitions/http.AuthResponse'
|
||||
$ref: '#/definitions/http.RegisterResponse'
|
||||
"400":
|
||||
description: 'Invalid input: email format, password requirements, or timezone'
|
||||
description: 'Invalid input: email format or password requirements'
|
||||
schema:
|
||||
$ref: '#/definitions/http.ValidationErrorResponse'
|
||||
"403":
|
||||
description: Registration is closed
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
@@ -366,18 +631,182 @@ paths:
|
||||
summary: Register a new user
|
||||
tags:
|
||||
- auth
|
||||
/auth/resend-verification:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Resend the email verification link to the user's email address
|
||||
parameters:
|
||||
- description: User email
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ResendVerificationRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Verification email sent
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid email
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
description: Email already verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Resend verification email
|
||||
tags:
|
||||
- auth
|
||||
/auth/reset-password:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Reset user password using the reset token from email
|
||||
parameters:
|
||||
- description: Reset token and new password
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ResetPasswordRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Password reset successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid token or password requirements not met
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Reset password
|
||||
tags:
|
||||
- auth
|
||||
/auth/verify-email:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Verify user email address using the token sent via email
|
||||
parameters:
|
||||
- description: Verification token
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.VerifyEmailRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Email verified successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid or expired token
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
description: Email already verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Verify email address
|
||||
tags:
|
||||
- auth
|
||||
/export:
|
||||
get:
|
||||
description: Export all user habits and entries in JSON format with gzip compression.
|
||||
Limited to 1 export per hour.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Compressed JSON export
|
||||
schema:
|
||||
$ref: '#/definitions/queries.ExportUserDataResult'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"429":
|
||||
description: Rate limit exceeded
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Export user data
|
||||
tags:
|
||||
- export
|
||||
/habits:
|
||||
get:
|
||||
description: Get all active habits for the authenticated user
|
||||
description: Get all active habits for the authenticated user with optional
|
||||
pagination and filters
|
||||
parameters:
|
||||
- description: 'Page number (default: 1)'
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: 'Page size (default: 50, max: 100)'
|
||||
in: query
|
||||
name: page_size
|
||||
type: integer
|
||||
- description: Filter by type (BOOLEAN, COUNTER, VALUE)
|
||||
in: query
|
||||
name: type
|
||||
type: string
|
||||
- description: Filter by frequency (DAILY, WEEKLY, MONTHLY)
|
||||
in: query
|
||||
name: frequency
|
||||
type: string
|
||||
- description: 'Include archived habits (default: false)'
|
||||
in: query
|
||||
name: archived
|
||||
type: boolean
|
||||
- description: Search by name or description
|
||||
in: query
|
||||
name: search
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/http.UserHabitResponse'
|
||||
type: array
|
||||
$ref: '#/definitions/http.GetUserHabitsResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
@@ -708,7 +1137,15 @@ paths:
|
||||
- habits
|
||||
/habits/today:
|
||||
get:
|
||||
description: Get all habits scheduled for today for the authenticated user
|
||||
description: Get all habits scheduled for today for the authenticated user.
|
||||
Includes the entry for today if it exists. Requires timezone as query parameter
|
||||
(e.g., ?timezone=America/New_York).
|
||||
parameters:
|
||||
- description: IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')
|
||||
in: query
|
||||
name: timezone
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -718,6 +1155,10 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/http.TodaysHabitResponse'
|
||||
type: array
|
||||
"400":
|
||||
description: Invalid or missing timezone
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
@@ -750,8 +1191,7 @@ paths:
|
||||
- system
|
||||
/stats/habits/{id}:
|
||||
get:
|
||||
description: Get statistics for a specific habit including streaks and completion
|
||||
rates
|
||||
description: Get statistics for a specific habit including streaks and completions
|
||||
parameters:
|
||||
- description: Habit ID
|
||||
in: path
|
||||
@@ -786,6 +1226,110 @@ paths:
|
||||
summary: Get habit statistics
|
||||
tags:
|
||||
- stats
|
||||
/sync/batch:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Apply a batch of changes from the client for offline sync (Last-Write-Wins)
|
||||
parameters:
|
||||
- description: Sync batch data
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.SyncBatchRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Apply sync batch
|
||||
tags:
|
||||
- sync
|
||||
/sync/changes:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get all changes (habits and entries) since a given timestamp for
|
||||
offline sync
|
||||
parameters:
|
||||
- description: ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)
|
||||
in: query
|
||||
name: since
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/http.SyncChangesResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get sync changes
|
||||
tags:
|
||||
- sync
|
||||
/users/me:
|
||||
delete:
|
||||
description: Permanently delete the authenticated user's account and all associated
|
||||
data (habits, entries, tokens). This action cannot be undone.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Account deleted successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing token
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete user account
|
||||
tags:
|
||||
- users
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitBatchChanges struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type EntryBatchChanges struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type ApplySyncBatchCommand struct {
|
||||
UserID string
|
||||
Habits HabitBatchChanges
|
||||
Entries EntryBatchChanges
|
||||
}
|
||||
|
||||
type ApplySyncBatchHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewApplySyncBatchHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *ApplySyncBatchHandler {
|
||||
return &ApplySyncBatchHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ApplySyncBatchHandler) Handle(ctx context.Context, cmd ApplySyncBatchCommand) error {
|
||||
if cmd.UserID == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
for _, habit := range cmd.Habits.Created {
|
||||
if habit.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
if err := h.habitRepo.Create(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, habit := range cmd.Habits.Updated {
|
||||
if habit.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
existing, err := h.habitRepo.FindByID(ctx, habit.ID)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
if err := h.habitRepo.Create(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
if shouldApplyUpdate(existing.UpdatedAt, habit.UpdatedAt) {
|
||||
if err := h.habitRepo.Update(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range cmd.Habits.Deleted {
|
||||
existing, err := h.habitRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
if err := h.habitRepo.SoftDelete(ctx, id); err != nil && err != errors.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range cmd.Entries.Created {
|
||||
if err := h.entryRepo.Create(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range cmd.Entries.Updated {
|
||||
existing, err := h.entryRepo.FindByID(ctx, entry.ID)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
if err := h.entryRepo.Create(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if shouldApplyUpdate(existing.UpdatedAt, entry.UpdatedAt) {
|
||||
if err := h.entryRepo.Update(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range cmd.Entries.Deleted {
|
||||
if err := h.entryRepo.SoftDelete(ctx, id); err != nil && err != errors.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldApplyUpdate(serverTime, clientTime time.Time) bool {
|
||||
return clientTime.After(serverTime)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepoForBatch struct {
|
||||
habits map[string]*entities.Habit
|
||||
createFunc func(ctx context.Context, habit *entities.Habit) error
|
||||
updateFunc func(ctx context.Context, habit *entities.Habit) error
|
||||
softDeleteFunc func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
habit, ok := m.habits[id]
|
||||
if !ok {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return habit, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, habit)
|
||||
}
|
||||
m.habits[habit.ID] = habit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, habit)
|
||||
}
|
||||
m.habits[habit.ID] = habit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) SoftDelete(ctx context.Context, id string) error {
|
||||
if m.softDeleteFunc != nil {
|
||||
return m.softDeleteFunc(ctx, id)
|
||||
}
|
||||
delete(m.habits, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type mockEntryRepoForBatch struct {
|
||||
entries map[string]*entities.HabitEntry
|
||||
createFunc func(ctx context.Context, entry *entities.HabitEntry) error
|
||||
updateFunc func(ctx context.Context, entry *entities.HabitEntry) error
|
||||
softDeleteFunc func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
entry, ok := m.entries[id]
|
||||
if !ok {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Create(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, entry)
|
||||
}
|
||||
m.entries[entry.ID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, entry)
|
||||
}
|
||||
m.entries[entry.ID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) SoftDelete(ctx context.Context, id string) error {
|
||||
if m.softDeleteFunc != nil {
|
||||
return m.softDeleteFunc(ctx, id)
|
||||
}
|
||||
delete(m.entries, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_CreateNewHabits(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
newHabit := entities.NewHabit("user-123", "New Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
newHabit.ID = "habit-new"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{newHabit},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(habitRepo.habits) != 1 {
|
||||
t.Errorf("Expected 1 habit created, got %d", len(habitRepo.habits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_UpdateExistingHabits(t *testing.T) {
|
||||
oldTime := time.Now().Add(-1 * time.Hour)
|
||||
newTime := time.Now()
|
||||
|
||||
existingHabit := entities.NewHabit("user-123", "Old Name", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
existingHabit.ID = "habit-1"
|
||||
existingHabit.UpdatedAt = oldTime
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": existingHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
updatedHabit := entities.NewHabit("user-123", "New Name", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
updatedHabit.ID = "habit-1"
|
||||
updatedHabit.UpdatedAt = newTime
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{updatedHabit},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if habitRepo.habits["habit-1"].Name != "New Name" {
|
||||
t.Errorf("Expected habit name to be updated to 'New Name', got '%s'", habitRepo.habits["habit-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_LastWriteWins(t *testing.T) {
|
||||
serverTime := time.Now()
|
||||
clientTime := serverTime.Add(-30 * time.Minute)
|
||||
|
||||
serverHabit := entities.NewHabit("user-123", "Server Version", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
serverHabit.ID = "habit-1"
|
||||
serverHabit.UpdatedAt = serverTime
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": serverHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
clientHabit := entities.NewHabit("user-123", "Client Version", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
clientHabit.ID = "habit-1"
|
||||
clientHabit.UpdatedAt = clientTime
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{clientHabit},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if habitRepo.habits["habit-1"].Name != "Server Version" {
|
||||
t.Errorf("Expected server version to win (Last-Write-Wins), got '%s'", habitRepo.habits["habit-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_DeleteHabits(t *testing.T) {
|
||||
existingHabit := entities.NewHabit("user-123", "To Delete", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
existingHabit.ID = "habit-1"
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": existingHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{"habit-1"},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(habitRepo.habits) != 0 {
|
||||
t.Errorf("Expected habit to be deleted, but still exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_ValidatesUserOwnership(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
habitForDifferentUser := entities.NewHabit("user-456", "Not Yours", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habitForDifferentUser.ID = "habit-1"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{habitForDifferentUser},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for user mismatch, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_ProcessesEntries(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
newEntry := entities.NewHabitEntry("habit-1", time.Now(), nil)
|
||||
newEntry.ID = "entry-new"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{newEntry},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(entryRepo.entries) != 1 {
|
||||
t.Errorf("Expected 1 entry created, got %d", len(entryRepo.entries))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
@@ -38,6 +39,34 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateHabitHandler_Success(t *testing.T) {
|
||||
mock := &mockHabitRepo{
|
||||
createFunc: func(ctx context.Context, habit *entities.Habit) error {
|
||||
@@ -231,19 +260,3 @@ func TestCreateHabitHandler_NegativeHabit(t *testing.T) {
|
||||
t.Error("Expected habit ID to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -59,6 +59,18 @@ func (m *mockEntryRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockHabitRepoForMark struct {
|
||||
habit *entities.Habit
|
||||
}
|
||||
@@ -575,3 +587,15 @@ func (m *mockHabitRepoForMark) FindByUserIDFiltered(ctx context.Context, userID
|
||||
func (m *mockHabitRepoForMark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
||||
user := entities.NewUser(cmd.Email, hashedPassword)
|
||||
|
||||
emailVerificationRequired := false
|
||||
if h.emailService != nil {
|
||||
if h.emailService.IsEnabled() {
|
||||
token, err := h.generateVerificationToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate verification token: %w", err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -71,7 +72,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -88,7 +89,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
if result.EmailVerificationRequired {
|
||||
t.Error("expected email verification to not be required when emailService is nil")
|
||||
t.Error("expected email verification to not be required when email is disabled")
|
||||
}
|
||||
|
||||
if createdUser == nil {
|
||||
@@ -103,7 +104,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -135,7 +136,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -174,7 +175,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -195,7 +196,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
|
||||
return "", expectedErr
|
||||
},
|
||||
}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -216,7 +217,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -232,7 +233,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -285,7 +286,7 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "closed", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
|
||||
@@ -89,6 +89,10 @@ func (m *mockRequestResetEmailService) HealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetEmailService) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
|
||||
@@ -155,3 +155,15 @@ func (m *mockEntryRepoForUnmark) FindByUserIDFiltered(ctx context.Context, userI
|
||||
func (m *mockEntryRepoForUnmark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
@@ -13,10 +14,11 @@ type UpdateHabitCommand struct {
|
||||
UserID string
|
||||
Name string
|
||||
Description string
|
||||
CarryOver bool
|
||||
TargetValue *float64
|
||||
Frequency value_objects.Frequency
|
||||
SpecificDays []int
|
||||
SpecificDates []int
|
||||
CarryOver bool
|
||||
TargetValue *float64
|
||||
}
|
||||
|
||||
type UpdateHabitHandler struct {
|
||||
@@ -34,6 +36,18 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if !cmd.Frequency.IsValid() {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -49,10 +63,11 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
|
||||
|
||||
habit.Name = cmd.Name
|
||||
habit.Description = cmd.Description
|
||||
habit.CarryOver = cmd.CarryOver
|
||||
habit.TargetValue = cmd.TargetValue
|
||||
habit.Frequency = cmd.Frequency
|
||||
habit.SpecificDays = cmd.SpecificDays
|
||||
habit.SpecificDates = cmd.SpecificDates
|
||||
habit.CarryOver = cmd.CarryOver
|
||||
habit.TargetValue = cmd.TargetValue
|
||||
|
||||
return h.habitRepo.Update(ctx, habit)
|
||||
}
|
||||
|
||||
@@ -48,9 +48,10 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
Name: "Morning Exercise",
|
||||
Description: "Updated description",
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
CarryOver: true,
|
||||
TargetValue: &newTargetValue,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -67,6 +68,14 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
t.Errorf("Expected description to be updated, got %s", habitRepo.updatedHabit.Description)
|
||||
}
|
||||
|
||||
if habitRepo.updatedHabit.Frequency != value_objects.FrequencyWeekly {
|
||||
t.Errorf("Expected frequency WEEKLY, got %s", habitRepo.updatedHabit.Frequency)
|
||||
}
|
||||
|
||||
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
|
||||
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
|
||||
}
|
||||
|
||||
if !habitRepo.updatedHabit.CarryOver {
|
||||
t.Error("Expected CarryOver to be true")
|
||||
}
|
||||
@@ -74,10 +83,6 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
if habitRepo.updatedHabit.TargetValue == nil || *habitRepo.updatedHabit.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", habitRepo.updatedHabit.TargetValue)
|
||||
}
|
||||
|
||||
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
|
||||
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
|
||||
@@ -88,9 +93,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "non-existent",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
HabitID: "non-existent",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -111,9 +117,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-456", // Different user
|
||||
Name: "Exercise",
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-456", // Different user
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -135,9 +142,10 @@ func TestUpdateHabitHandler_CannotUpdateArchivedHabit(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Updated Exercise",
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Updated Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -158,9 +166,10 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "", // Empty name
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "", // Empty name
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -169,3 +178,57 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
|
||||
t.Errorf("Expected ErrInvalidInput for empty name, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_InvalidFrequency(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: "INVALID",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for invalid frequency, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_WeeklyRequiresSpecificDays(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for weekly without specific days, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_MonthlyRequiresSpecificDates(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyMonthly,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for monthly without specific dates, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ func (m *mockEmailService) HealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEmailService) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_Success(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
@@ -6,18 +6,18 @@ import (
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitStatsDTO struct {
|
||||
HabitID string `json:"habit_id"`
|
||||
HabitName string `json:"habit_name"`
|
||||
TotalCompletions int `json:"total_completions"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
LongestStreak int `json:"longest_streak"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
CompletionsThisWeek int `json:"completions_this_week"`
|
||||
CompletionsThisMonth int `json:"completions_this_month"`
|
||||
HabitID string `json:"habit_id"`
|
||||
HabitName string `json:"habit_name"`
|
||||
TotalCompletions int `json:"total_completions"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
LongestStreak int `json:"longest_streak"`
|
||||
CompletionsThisWeek int `json:"completions_this_week"`
|
||||
CompletionsThisMonth int `json:"completions_this_month"`
|
||||
}
|
||||
|
||||
type GetHabitStatsQuery struct {
|
||||
@@ -60,102 +60,21 @@ func (h *GetHabitStatsHandler) Handle(ctx context.Context, query GetHabitStatsQu
|
||||
HabitName: habit.Name,
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
if len(entries) == 0 && !habit.IsNegative {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
stats.TotalCompletions = len(entries)
|
||||
stats.CurrentStreak = calculateCurrentStreak(entries)
|
||||
stats.LongestStreak = calculateLongestStreak(entries)
|
||||
stats.CompletionRate = calculateCompletionRate(entries, habit.CreatedAt)
|
||||
stats.CompletionsThisWeek = countCompletionsInPeriod(entries, 7)
|
||||
stats.CompletionsThisMonth = countCompletionsInPeriod(entries, 30)
|
||||
|
||||
streaks := services.CalculateStreaks(entries, habit, time.Now().UTC())
|
||||
stats.CurrentStreak = streaks.Current
|
||||
stats.LongestStreak = streaks.Longest
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func calculateCurrentStreak(entries []*entities.HabitEntry) int {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
dateMap := make(map[string]bool)
|
||||
for _, entry := range entries {
|
||||
dateStr := entry.ScheduledDate.Format("2006-01-02")
|
||||
dateMap[dateStr] = true
|
||||
}
|
||||
|
||||
streak := 0
|
||||
currentDate := time.Now().UTC()
|
||||
|
||||
for {
|
||||
dateStr := currentDate.Format("2006-01-02")
|
||||
if !dateMap[dateStr] {
|
||||
break
|
||||
}
|
||||
streak++
|
||||
currentDate = currentDate.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
func calculateLongestStreak(entries []*entities.HabitEntry) int {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
dateMap := make(map[string]bool)
|
||||
var dates []time.Time
|
||||
for _, entry := range entries {
|
||||
date := time.Date(entry.ScheduledDate.Year(), entry.ScheduledDate.Month(), entry.ScheduledDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
if !dateMap[dateStr] {
|
||||
dateMap[dateStr] = true
|
||||
dates = append(dates, date)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dates) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
longestStreak := 1
|
||||
currentStreak := 1
|
||||
|
||||
for i := 1; i < len(dates); i++ {
|
||||
diff := dates[i].Sub(dates[i-1]).Hours() / 24
|
||||
if diff == 1 {
|
||||
currentStreak++
|
||||
if currentStreak > longestStreak {
|
||||
longestStreak = currentStreak
|
||||
}
|
||||
} else {
|
||||
currentStreak = 1
|
||||
}
|
||||
}
|
||||
|
||||
return longestStreak
|
||||
}
|
||||
|
||||
func calculateCompletionRate(entries []*entities.HabitEntry, createdAt time.Time) float64 {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
daysSinceCreation := int(time.Since(createdAt).Hours() / 24)
|
||||
if daysSinceCreation == 0 {
|
||||
daysSinceCreation = 1
|
||||
}
|
||||
|
||||
rate := float64(len(entries)) / float64(daysSinceCreation) * 100
|
||||
if rate > 100 {
|
||||
rate = 100
|
||||
}
|
||||
|
||||
return rate
|
||||
}
|
||||
|
||||
func countCompletionsInPeriod(entries []*entities.HabitEntry, days int) int {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||
count := 0
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitChangesDTO struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type EntryChangesDTO struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type SyncChangesDTO struct {
|
||||
Habits HabitChangesDTO
|
||||
Entries EntryChangesDTO
|
||||
}
|
||||
|
||||
type GetSyncChangesQuery struct {
|
||||
UserID string
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
type GetSyncChangesHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewGetSyncChangesHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *GetSyncChangesHandler {
|
||||
return &GetSyncChangesHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GetSyncChangesHandler) Handle(ctx context.Context, query GetSyncChangesQuery) (*SyncChangesDTO, error) {
|
||||
if query.UserID == "" {
|
||||
return nil, errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
habitChanges, err := h.habitRepo.GetChangesSince(ctx, query.UserID, query.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entryChanges, err := h.entryRepo.GetChangesSince(ctx, query.UserID, query.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SyncChangesDTO{
|
||||
Habits: HabitChangesDTO{
|
||||
Created: habitChanges.Created,
|
||||
Updated: habitChanges.Updated,
|
||||
Deleted: habitChanges.Deleted,
|
||||
},
|
||||
Entries: EntryChangesDTO{
|
||||
Created: entryChanges.Created,
|
||||
Updated: entryChanges.Updated,
|
||||
Deleted: entryChanges.Deleted,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepoForSync struct {
|
||||
changes *repositories.HabitChanges
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return m.changes, m.err
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockEntryRepoForSync struct {
|
||||
changes *repositories.HabitEntryChanges
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return m.changes, m.err
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Create(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
since := now.Add(-1 * time.Hour)
|
||||
|
||||
createdHabit := entities.NewHabit("user-123", "New Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
createdHabit.ID = "habit-1"
|
||||
createdHabit.CreatedAt = now
|
||||
createdHabit.UpdatedAt = now
|
||||
|
||||
updatedHabit := entities.NewHabit("user-123", "Updated Habit", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
updatedHabit.ID = "habit-2"
|
||||
updatedHabit.CreatedAt = since.Add(-1 * time.Hour)
|
||||
updatedHabit.UpdatedAt = now
|
||||
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{createdHabit},
|
||||
Updated: []*entities.Habit{updatedHabit},
|
||||
Deleted: []string{"habit-3"},
|
||||
},
|
||||
}
|
||||
|
||||
createdEntry := entities.NewHabitEntry("habit-1", now, nil)
|
||||
createdEntry.ID = "entry-1"
|
||||
|
||||
updatedEntry := entities.NewHabitEntry("habit-2", now, nil)
|
||||
updatedEntry.ID = "entry-2"
|
||||
updatedEntry.UpdatedAt = now
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{createdEntry},
|
||||
Updated: []*entities.HabitEntry{updatedEntry},
|
||||
Deleted: []string{"entry-3"},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "user-123",
|
||||
Since: since,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits.Created) != 1 {
|
||||
t.Errorf("Expected 1 created habit, got %d", len(result.Habits.Created))
|
||||
}
|
||||
|
||||
if len(result.Habits.Updated) != 1 {
|
||||
t.Errorf("Expected 1 updated habit, got %d", len(result.Habits.Updated))
|
||||
}
|
||||
|
||||
if len(result.Habits.Deleted) != 1 {
|
||||
t.Errorf("Expected 1 deleted habit, got %d", len(result.Habits.Deleted))
|
||||
}
|
||||
|
||||
if len(result.Entries.Created) != 1 {
|
||||
t.Errorf("Expected 1 created entry, got %d", len(result.Entries.Created))
|
||||
}
|
||||
|
||||
if len(result.Entries.Updated) != 1 {
|
||||
t.Errorf("Expected 1 updated entry, got %d", len(result.Entries.Updated))
|
||||
}
|
||||
|
||||
if len(result.Entries.Deleted) != 1 {
|
||||
t.Errorf("Expected 1 deleted entry, got %d", len(result.Entries.Deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_EmptyChanges(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "user-123",
|
||||
Since: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits.Created) != 0 {
|
||||
t.Errorf("Expected 0 created habits, got %d", len(result.Habits.Created))
|
||||
}
|
||||
|
||||
if len(result.Entries.Created) != 0 {
|
||||
t.Errorf("Expected 0 created entries, got %d", len(result.Entries.Created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_InvalidUserID(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "",
|
||||
Since: time.Now(),
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty UserID, got nil")
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,18 @@ func (m *mockEntryRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetTodaysHabitsHandler_DailyHabitNoEntries(t *testing.T) {
|
||||
habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit.ID = "habit-1"
|
||||
@@ -337,3 +349,15 @@ func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string,
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
@@ -333,6 +334,18 @@ func (m *mockGetUserHabitsRepo) CountByUserIDFiltered(ctx context.Context, userI
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_WithFilters(t *testing.T) {
|
||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.ID = "habit-1"
|
||||
|
||||
@@ -19,7 +19,9 @@ type Habit struct {
|
||||
IsNegative bool
|
||||
TargetValue *float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ArchivedAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
func NewHabit(
|
||||
@@ -30,6 +32,7 @@ func NewHabit(
|
||||
carryOver bool,
|
||||
isNegative bool,
|
||||
) *Habit {
|
||||
now := time.Now()
|
||||
return &Habit{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
@@ -37,15 +40,31 @@ func NewHabit(
|
||||
Frequency: frequency,
|
||||
CarryOver: carryOver,
|
||||
IsNegative: isNegative,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Habit) Archive() {
|
||||
now := time.Now()
|
||||
h.ArchivedAt = &now
|
||||
h.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (h *Habit) IsActive() bool {
|
||||
return h.ArchivedAt == nil
|
||||
}
|
||||
|
||||
func (h *Habit) Delete() {
|
||||
now := time.Now()
|
||||
h.DeletedAt = &now
|
||||
h.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (h *Habit) IsDeleted() bool {
|
||||
return h.DeletedAt != nil
|
||||
}
|
||||
|
||||
func (h *Habit) Touch() {
|
||||
h.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
@@ -8,13 +8,27 @@ type HabitEntry struct {
|
||||
ScheduledDate time.Time
|
||||
CompletedAt time.Time
|
||||
Value *float64
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
func NewHabitEntry(habitID string, scheduledDate time.Time, value *float64) *HabitEntry {
|
||||
now := time.Now()
|
||||
return &HabitEntry{
|
||||
HabitID: habitID,
|
||||
ScheduledDate: scheduledDate,
|
||||
CompletedAt: time.Now(),
|
||||
CompletedAt: now,
|
||||
Value: value,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HabitEntry) Delete() {
|
||||
now := time.Now()
|
||||
e.DeletedAt = &now
|
||||
e.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (e *HabitEntry) IsDeleted() bool {
|
||||
return e.DeletedAt != nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import (
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
)
|
||||
|
||||
type HabitEntryChanges struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type HabitEntryRepository interface {
|
||||
Create(ctx context.Context, entry *entities.HabitEntry) error
|
||||
FindByID(ctx context.Context, id string) (*entities.HabitEntry, error)
|
||||
@@ -16,4 +22,8 @@ type HabitEntryRepository interface {
|
||||
FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error)
|
||||
Update(ctx context.Context, entry *entities.HabitEntry) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Sync methods
|
||||
GetChangesSince(ctx context.Context, userID string, since time.Time) (*HabitEntryChanges, error)
|
||||
SoftDelete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
@@ -15,6 +16,12 @@ type HabitFilter struct {
|
||||
Search string
|
||||
}
|
||||
|
||||
type HabitChanges struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type HabitRepository interface {
|
||||
Create(ctx context.Context, habit *entities.Habit) error
|
||||
FindByID(ctx context.Context, id string) (*entities.Habit, error)
|
||||
@@ -26,4 +33,8 @@ type HabitRepository interface {
|
||||
CountByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter) (int, error)
|
||||
Update(ctx context.Context, habit *entities.Habit) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Sync methods
|
||||
GetChangesSince(ctx context.Context, userID string, since time.Time) (*HabitChanges, error)
|
||||
SoftDelete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -10,4 +10,11 @@ type EmailMessage struct {
|
||||
type EmailService interface {
|
||||
Send(message EmailMessage) error
|
||||
HealthCheck() error
|
||||
IsEnabled() bool
|
||||
}
|
||||
|
||||
type NoOpEmailService struct{}
|
||||
|
||||
func (n *NoOpEmailService) Send(_ EmailMessage) error { return nil }
|
||||
func (n *NoOpEmailService) HealthCheck() error { return nil }
|
||||
func (n *NoOpEmailService) IsEnabled() bool { return false }
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/utils"
|
||||
)
|
||||
|
||||
type StreakResult struct {
|
||||
Current int
|
||||
Longest int
|
||||
}
|
||||
|
||||
func CalculateStreaks(entries []*entities.HabitEntry, habit *entities.Habit, now time.Time) StreakResult {
|
||||
entryMap := buildEntryMap(entries)
|
||||
scheduled := allScheduledDates(habit, now)
|
||||
|
||||
if len(scheduled) == 0 {
|
||||
return StreakResult{}
|
||||
}
|
||||
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
longest := 0
|
||||
current := 0
|
||||
|
||||
for _, d := range scheduled {
|
||||
if IsDaySuccessful(d.Format("2006-01-02"), entryMap, habit) {
|
||||
current++
|
||||
if current > longest {
|
||||
longest = current
|
||||
}
|
||||
} else if d.Equal(today) && !habit.IsNegative {
|
||||
continue
|
||||
} else {
|
||||
current = 0
|
||||
}
|
||||
}
|
||||
|
||||
return StreakResult{Current: current, Longest: longest}
|
||||
}
|
||||
|
||||
func buildEntryMap(entries []*entities.HabitEntry) map[string]*entities.HabitEntry {
|
||||
m := make(map[string]*entities.HabitEntry)
|
||||
for _, e := range entries {
|
||||
m[e.ScheduledDate.Format("2006-01-02")] = e
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func allScheduledDates(habit *entities.Habit, now time.Time) []time.Time {
|
||||
createdUTC := habit.CreatedAt.UTC()
|
||||
start := time.Date(createdUTC.Year(), createdUTC.Month(), createdUTC.Day(), 0, 0, 0, 0, time.UTC)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
freq := string(habit.Frequency)
|
||||
|
||||
var dates []time.Time
|
||||
for d := start; !d.After(today); d = d.AddDate(0, 0, 1) {
|
||||
if utils.ShouldAppearToday(freq, habit.SpecificDays, habit.SpecificDates, d) {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
func IsDaySuccessful(dateStr string, entryMap map[string]*entities.HabitEntry, habit *entities.Habit) bool {
|
||||
entry, hasEntry := entryMap[dateStr]
|
||||
habitType := string(habit.Type)
|
||||
|
||||
if !habit.IsNegative {
|
||||
if !hasEntry {
|
||||
return false
|
||||
}
|
||||
if habit.TargetValue != nil && entry.Value != nil {
|
||||
return *entry.Value >= *habit.TargetValue
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if habitType == "VALUE" && habit.TargetValue != nil {
|
||||
if !hasEntry {
|
||||
return false
|
||||
}
|
||||
if entry.Value != nil {
|
||||
return *entry.Value <= *habit.TargetValue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if !hasEntry {
|
||||
return true
|
||||
}
|
||||
|
||||
if habit.TargetValue != nil && entry.Value != nil {
|
||||
return *entry.Value <= *habit.TargetValue
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
)
|
||||
|
||||
func makeEntry(d time.Time, value *float64) *entities.HabitEntry {
|
||||
return &entities.HabitEntry{ScheduledDate: d, Value: value}
|
||||
}
|
||||
|
||||
func floatPtr(v float64) *float64 { return &v }
|
||||
|
||||
func dt(year, month, day int) time.Time {
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func habit(t value_objects.HabitType, negative bool, target *float64) *entities.Habit {
|
||||
return &entities.Habit{
|
||||
Type: t,
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
IsNegative: negative,
|
||||
TargetValue: target,
|
||||
CreatedAt: dt(2026, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// --- IsDaySuccessful ---
|
||||
|
||||
func TestIsDaySuccessful_BooleanPositive(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, false, nil)
|
||||
em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)}
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", em, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_BooleanNegative(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, true, nil)
|
||||
em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)}
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success (resisted)")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterPositiveNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterPositiveWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, floatPtr(8))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"value >= target", floatPtr(10), true, true},
|
||||
{"value == target", floatPtr(8), true, true},
|
||||
{"value < target", floatPtr(3), true, false},
|
||||
{"no entry", nil, false, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterNegativeNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, true, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterNegativeWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, true, floatPtr(2))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"no entry", nil, false, true},
|
||||
{"within limit", floatPtr(1), true, true},
|
||||
{"at limit", floatPtr(2), true, true},
|
||||
{"over limit", floatPtr(5), true, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValuePositiveNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, false, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(72))}, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValuePositiveWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, false, floatPtr(7))
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(8))}, h) {
|
||||
t.Error("value >= target should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) {
|
||||
t.Error("value < target should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValueNegativeNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, true, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValueNegativeWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, true, floatPtr(70))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"below target", floatPtr(68), true, true},
|
||||
{"above target", floatPtr(75), true, false},
|
||||
{"no entry (didnt track)", nil, false, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- CalculateStreaks ---
|
||||
|
||||
func TestStreaks_DailyBooleanPositive(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, false, nil)
|
||||
|
||||
t.Run("3 consecutive days", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 3), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gap finds longest and current separately", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 3), nil),
|
||||
// gap Mar 4
|
||||
makeEntry(dt(2026, 3, 5), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 5))
|
||||
if r.Current != 1 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=1 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("today not completed doesnt break streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 2 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=2 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missed yesterday breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 4))
|
||||
if r.Current != 0 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=0 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_DailyBooleanNegative(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, true, nil)
|
||||
h.CreatedAt = dt(2026, 3, 1)
|
||||
|
||||
t.Run("3 days no entries is 3 streak", func(t *testing.T) {
|
||||
r := CalculateStreaks(nil, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entry breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{makeEntry(dt(2026, 3, 2), nil)}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 1 || r.Longest != 1 {
|
||||
t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_WeeklyBooleanPositive(t *testing.T) {
|
||||
h := &entities.Habit{
|
||||
Type: value_objects.HabitTypeBoolean,
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
CreatedAt: dt(2026, 3, 1),
|
||||
}
|
||||
|
||||
t.Run("3 consecutive scheduled days", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 4), nil),
|
||||
makeEntry(dt(2026, 3, 6), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 6))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missed Wednesday breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 6), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 6))
|
||||
if r.Current != 1 || r.Longest != 1 {
|
||||
t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_CounterWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, floatPtr(8))
|
||||
|
||||
t.Run("all meet target", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), floatPtr(8)),
|
||||
makeEntry(dt(2026, 3, 2), floatPtr(10)),
|
||||
makeEntry(dt(2026, 3, 3), floatPtr(9)),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 {
|
||||
t.Errorf("Expected current=3, got %d", r.Current)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_WeeklyCounterNegativeWithTarget(t *testing.T) {
|
||||
h := &entities.Habit{
|
||||
Type: value_objects.HabitTypeCounter,
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 5},
|
||||
IsNegative: true,
|
||||
TargetValue: floatPtr(2),
|
||||
CreatedAt: dt(2026, 3, 1),
|
||||
}
|
||||
|
||||
t.Run("within limit and no entry both count as success", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), floatPtr(1)),
|
||||
makeEntry(dt(2026, 3, 9), floatPtr(5)),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 13))
|
||||
if r.Current != 1 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=1 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -107,6 +107,10 @@ func (s *SMTPService) GetConfig() SMTPConfig {
|
||||
return s.config
|
||||
}
|
||||
|
||||
func (s *SMTPService) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *SMTPService) HealthCheck() error {
|
||||
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
|
||||
dialer.TLSConfig = &tls.Config{
|
||||
|
||||
@@ -20,12 +20,13 @@ type CreateHabitRequest struct {
|
||||
}
|
||||
|
||||
type UpdateHabitRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Frequency value_objects.Frequency `json:"frequency"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
}
|
||||
|
||||
type HabitResponse struct {
|
||||
@@ -105,3 +106,51 @@ type ValidationErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
type SyncHabitDTO struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type value_objects.HabitType `json:"type"`
|
||||
Frequency value_objects.Frequency `json:"frequency"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
IsNegative bool `json:"is_negative"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
}
|
||||
|
||||
type SyncHabitEntryDTO struct {
|
||||
ID string `json:"id"`
|
||||
HabitID string `json:"habit_id"`
|
||||
ScheduledDate time.Time `json:"scheduled_date"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Value *float64 `json:"value,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type HabitChangesDTO struct {
|
||||
Created []SyncHabitDTO `json:"created"`
|
||||
Updated []SyncHabitDTO `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
}
|
||||
|
||||
type EntryChangesDTO struct {
|
||||
Created []SyncHabitEntryDTO `json:"created"`
|
||||
Updated []SyncHabitEntryDTO `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
}
|
||||
|
||||
type SyncChangesResponse struct {
|
||||
Habits HabitChangesDTO `json:"habits"`
|
||||
Entries EntryChangesDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type SyncBatchRequest struct {
|
||||
Habits HabitChangesDTO `json:"habits"`
|
||||
Entries EntryChangesDTO `json:"entries"`
|
||||
}
|
||||
|
||||
@@ -313,10 +313,11 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CarryOver: req.CarryOver,
|
||||
TargetValue: req.TargetValue,
|
||||
Frequency: req.Frequency,
|
||||
SpecificDays: req.SpecificDays,
|
||||
SpecificDates: req.SpecificDates,
|
||||
CarryOver: req.CarryOver,
|
||||
TargetValue: req.TargetValue,
|
||||
}
|
||||
|
||||
if err := h.updateHandler.Handle(r.Context(), cmd); err != nil {
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestHabitCRUDFlow(t *testing.T) {
|
||||
reqBody := UpdateHabitRequest{
|
||||
Name: "Morning Exercise",
|
||||
Description: "Updated description",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, reqBody, token)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/crypto"
|
||||
@@ -43,14 +44,15 @@ func setupTestServer(t *testing.T) *TestServer {
|
||||
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
|
||||
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db)
|
||||
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open", false)
|
||||
noOpEmail := &services.NoOpEmailService{}
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, noOpEmail, "", "open", false)
|
||||
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
||||
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
||||
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
||||
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
||||
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, nil, false)
|
||||
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, nil, "")
|
||||
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, nil, "")
|
||||
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, noOpEmail, false)
|
||||
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, noOpEmail, "")
|
||||
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, noOpEmail, "")
|
||||
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
|
||||
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
||||
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
||||
@@ -70,14 +72,18 @@ func setupTestServer(t *testing.T) *TestServer {
|
||||
|
||||
translator, _ := i18n.NewTranslator()
|
||||
|
||||
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
applySyncBatchHandler := commands.NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
|
||||
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
|
||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := NewHealthHandlers(db, nil)
|
||||
userHandlers := NewUserHandlers(deleteUserHandler, translator)
|
||||
exportHandlers := NewExportHandlers(exportUserDataHandler, translator)
|
||||
syncHandlers := NewSyncHandlers(getSyncChangesHandler, applySyncBatchHandler, translator)
|
||||
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
|
||||
|
||||
handler := http.Handler(router)
|
||||
return &TestServer{
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
_ "apocapoc-api/docs"
|
||||
)
|
||||
|
||||
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
|
||||
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, syncHandlers *SyncHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(logger.Middleware)
|
||||
@@ -30,6 +30,9 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
r.Get("/docs", httpSwagger.Handler(
|
||||
httpSwagger.URL("/api/v1/docs/doc.json"),
|
||||
))
|
||||
r.Get("/api/v1/docs", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/api/v1/docs/index.html", http.StatusMovedPermanently)
|
||||
})
|
||||
@@ -86,5 +89,12 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
|
||||
r.Get("/", exportHandlers.ExportData)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/sync", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(jwtService))
|
||||
r.Use(RateLimitByUser(jwtService, 100, 1*time.Minute))
|
||||
r.Get("/changes", syncHandlers.GetSyncChanges)
|
||||
r.Post("/batch", syncHandlers.ApplySyncBatch)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewStatsHandlers(
|
||||
|
||||
// GetHabitStats godoc
|
||||
// @Summary Get habit statistics
|
||||
// @Description Get statistics for a specific habit including streaks and completion rates
|
||||
// @Description Get statistics for a specific habit including streaks and completions
|
||||
// @Tags stats
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestHabitStatsFlow(t *testing.T) {
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
t.Run("Stats after marking habit once", func(t *testing.T) {
|
||||
t.Run("Stats after marking habit today", func(t *testing.T) {
|
||||
markReq := MarkHabitRequest{
|
||||
ScheduledDate: today,
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestHabitStatsFlow(t *testing.T) {
|
||||
t.Errorf("Expected 1 total completion, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 1 {
|
||||
t.Errorf("Expected current streak of 1, got %d", stats.CurrentStreak)
|
||||
t.Errorf("Expected current streak of 1 (today completed counts), got %d", stats.CurrentStreak)
|
||||
}
|
||||
if stats.LongestStreak != 1 {
|
||||
t.Errorf("Expected longest streak of 1, got %d", stats.LongestStreak)
|
||||
@@ -120,7 +120,8 @@ func TestHabitUpdateAffectsStats(t *testing.T) {
|
||||
|
||||
t.Run("Stats remain after updating habit name", func(t *testing.T) {
|
||||
updateReq := UpdateHabitRequest{
|
||||
Name: "Morning Running",
|
||||
Name: "Morning Running",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type SyncHandlers struct {
|
||||
getSyncChangesHandler *queries.GetSyncChangesHandler
|
||||
applySyncBatchHandler *commands.ApplySyncBatchHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewSyncHandlers(
|
||||
getSyncChangesHandler *queries.GetSyncChangesHandler,
|
||||
applySyncBatchHandler *commands.ApplySyncBatchHandler,
|
||||
translator *i18n.Translator,
|
||||
) *SyncHandlers {
|
||||
return &SyncHandlers{
|
||||
getSyncChangesHandler: getSyncChangesHandler,
|
||||
applySyncBatchHandler: applySyncBatchHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSyncChanges godoc
|
||||
// @Summary Get sync changes
|
||||
// @Description Get all changes (habits and entries) since a given timestamp for offline sync
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param since query string true "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)"
|
||||
// @Success 200 {object} SyncChangesResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /sync/changes [get]
|
||||
func (h *SyncHandlers) GetSyncChanges(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
sinceStr := r.URL.Query().Get("since")
|
||||
if sinceStr == "" {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "missing_since_parameter")
|
||||
return
|
||||
}
|
||||
|
||||
since, err := time.Parse(time.RFC3339, sinceStr)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_since_format")
|
||||
return
|
||||
}
|
||||
|
||||
query := queries.GetSyncChangesQuery{
|
||||
UserID: userID,
|
||||
Since: since,
|
||||
}
|
||||
|
||||
result, err := h.getSyncChangesHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
|
||||
return
|
||||
}
|
||||
|
||||
response := SyncChangesResponse{
|
||||
Habits: HabitChangesDTO{
|
||||
Created: toHabitDTOs(result.Habits.Created),
|
||||
Updated: toHabitDTOs(result.Habits.Updated),
|
||||
Deleted: result.Habits.Deleted,
|
||||
},
|
||||
Entries: EntryChangesDTO{
|
||||
Created: toHabitEntryDTOs(result.Entries.Created),
|
||||
Updated: toHabitEntryDTOs(result.Entries.Updated),
|
||||
Deleted: result.Entries.Deleted,
|
||||
},
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ApplySyncBatch godoc
|
||||
// @Summary Apply sync batch
|
||||
// @Description Apply a batch of changes from the client for offline sync (Last-Write-Wins)
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body SyncBatchRequest true "Sync batch data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /sync/batch [post]
|
||||
func (h *SyncHandlers) ApplySyncBatch(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req SyncBatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
habitChanges := commands.HabitBatchChanges{
|
||||
Created: fromHabitDTOs(req.Habits.Created),
|
||||
Updated: fromHabitDTOs(req.Habits.Updated),
|
||||
Deleted: req.Habits.Deleted,
|
||||
}
|
||||
|
||||
entryChanges := commands.EntryBatchChanges{
|
||||
Created: fromHabitEntryDTOs(req.Entries.Created),
|
||||
Updated: fromHabitEntryDTOs(req.Entries.Updated),
|
||||
Deleted: req.Entries.Deleted,
|
||||
}
|
||||
|
||||
cmd := commands.ApplySyncBatchCommand{
|
||||
UserID: userID,
|
||||
Habits: habitChanges,
|
||||
Entries: entryChanges,
|
||||
}
|
||||
|
||||
err := h.applySyncBatchHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "sync_batch_applied"})
|
||||
}
|
||||
|
||||
func toHabitDTOs(habits []*entities.Habit) []SyncHabitDTO {
|
||||
dtos := make([]SyncHabitDTO, len(habits))
|
||||
for i, h := range habits {
|
||||
dtos[i] = SyncHabitDTO{
|
||||
ID: h.ID,
|
||||
UserID: h.UserID,
|
||||
Name: h.Name,
|
||||
Description: h.Description,
|
||||
Type: h.Type,
|
||||
Frequency: h.Frequency,
|
||||
SpecificDays: h.SpecificDays,
|
||||
SpecificDates: h.SpecificDates,
|
||||
CarryOver: h.CarryOver,
|
||||
IsNegative: h.IsNegative,
|
||||
TargetValue: h.TargetValue,
|
||||
CreatedAt: h.CreatedAt,
|
||||
UpdatedAt: h.UpdatedAt,
|
||||
ArchivedAt: h.ArchivedAt,
|
||||
}
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
func fromHabitDTOs(dtos []SyncHabitDTO) []*entities.Habit {
|
||||
habits := make([]*entities.Habit, len(dtos))
|
||||
for i, dto := range dtos {
|
||||
habits[i] = &entities.Habit{
|
||||
ID: dto.ID,
|
||||
UserID: dto.UserID,
|
||||
Name: dto.Name,
|
||||
Description: dto.Description,
|
||||
Type: dto.Type,
|
||||
Frequency: dto.Frequency,
|
||||
SpecificDays: dto.SpecificDays,
|
||||
SpecificDates: dto.SpecificDates,
|
||||
CarryOver: dto.CarryOver,
|
||||
IsNegative: dto.IsNegative,
|
||||
TargetValue: dto.TargetValue,
|
||||
CreatedAt: dto.CreatedAt,
|
||||
UpdatedAt: dto.UpdatedAt,
|
||||
ArchivedAt: dto.ArchivedAt,
|
||||
}
|
||||
}
|
||||
return habits
|
||||
}
|
||||
|
||||
func toHabitEntryDTOs(entries []*entities.HabitEntry) []SyncHabitEntryDTO {
|
||||
dtos := make([]SyncHabitEntryDTO, len(entries))
|
||||
for i, e := range entries {
|
||||
dtos[i] = SyncHabitEntryDTO{
|
||||
ID: e.ID,
|
||||
HabitID: e.HabitID,
|
||||
ScheduledDate: e.ScheduledDate,
|
||||
CompletedAt: e.CompletedAt,
|
||||
Value: e.Value,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
func fromHabitEntryDTOs(dtos []SyncHabitEntryDTO) []*entities.HabitEntry {
|
||||
entries := make([]*entities.HabitEntry, len(dtos))
|
||||
for i, dto := range dtos {
|
||||
entries[i] = &entities.HabitEntry{
|
||||
ID: dto.ID,
|
||||
HabitID: dto.HabitID,
|
||||
ScheduledDate: dto.ScheduledDate,
|
||||
CompletedAt: dto.CompletedAt,
|
||||
Value: dto.Value,
|
||||
UpdatedAt: dto.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -24,8 +25,8 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
|
||||
entry.ID = uuid.New().String()
|
||||
|
||||
query := `
|
||||
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
@@ -34,6 +35,7 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
|
||||
entry.ScheduledDate.Format("2006-01-02"),
|
||||
entry.CompletedAt,
|
||||
entry.Value,
|
||||
entry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -52,11 +54,12 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
|
||||
from, to time.Time,
|
||||
) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
AND scheduled_date >= ?
|
||||
AND scheduled_date <= ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date ASC
|
||||
`
|
||||
|
||||
@@ -74,13 +77,15 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
entry.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habit_entries
|
||||
SET value = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
SET value = ?, completed_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.ID)
|
||||
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.UpdatedAt, entry.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update entry: %w", err)
|
||||
}
|
||||
@@ -100,6 +105,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -108,6 +115,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -123,6 +132,13 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
entries = append(entries, &entry)
|
||||
}
|
||||
|
||||
@@ -131,14 +147,16 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
|
||||
func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
@@ -147,6 +165,8 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -165,14 +185,21 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
WHERE habit_id = ? AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -187,10 +214,10 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
|
||||
|
||||
func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
WHERE h.user_id = ? AND he.deleted_at IS NULL
|
||||
ORDER BY he.scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -205,10 +232,11 @@ func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string)
|
||||
|
||||
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
AND scheduled_date < ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -236,3 +264,128 @@ func (r *HabitEntryRepository) Delete(ctx context.Context, id string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
changes := &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
AND he.updated_at > ?
|
||||
AND he.deleted_at IS NULL
|
||||
ORDER BY he.updated_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query habit entry changes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&entry.ID,
|
||||
&entry.HabitID,
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan habit entry: %w", err)
|
||||
}
|
||||
|
||||
parsedDate, err := time.Parse("2006-01-02", scheduledDate)
|
||||
if err != nil {
|
||||
parsedDate, err = time.Parse(time.RFC3339, scheduledDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse scheduled_date: %w", err)
|
||||
}
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
if entry.CompletedAt.After(since) {
|
||||
changes.Created = append(changes.Created, &entry)
|
||||
} else {
|
||||
changes.Updated = append(changes.Updated, &entry)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating habit entries: %w", err)
|
||||
}
|
||||
|
||||
queryDeleted := `
|
||||
SELECT he.id
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
AND he.deleted_at IS NOT NULL
|
||||
AND he.deleted_at > ?
|
||||
ORDER BY he.deleted_at ASC
|
||||
`
|
||||
|
||||
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deleted habit entries: %w", err)
|
||||
}
|
||||
defer rowsDeleted.Close()
|
||||
|
||||
for rowsDeleted.Next() {
|
||||
var id string
|
||||
if err := rowsDeleted.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan deleted habit entry id: %w", err)
|
||||
}
|
||||
changes.Deleted = append(changes.Deleted, id)
|
||||
}
|
||||
|
||||
if err := rowsDeleted.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating deleted habit entries: %w", err)
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) SoftDelete(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habit_entries
|
||||
SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to soft delete habit entry: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
@@ -31,8 +32,8 @@ func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) err
|
||||
query := `
|
||||
INSERT INTO habits (
|
||||
id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
@@ -48,6 +49,7 @@ func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) err
|
||||
habit.IsNegative,
|
||||
habit.TargetValue,
|
||||
habit.CreatedAt,
|
||||
habit.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -61,16 +63,18 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var (
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
@@ -86,7 +90,9 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -96,15 +102,22 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
return nil, fmt.Errorf("failed to find habit: %w", err)
|
||||
}
|
||||
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
if specificDays.Valid {
|
||||
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
||||
}
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
|
||||
return &habit, nil
|
||||
}
|
||||
@@ -113,9 +126,9 @@ func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string)
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
@@ -129,6 +142,8 @@ func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
habit.Touch()
|
||||
|
||||
specificDays, _ := json.Marshal(habit.SpecificDays)
|
||||
specificDates, _ := json.Marshal(habit.SpecificDates)
|
||||
|
||||
@@ -136,8 +151,8 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
|
||||
UPDATE habits
|
||||
SET name = ?, description = ?, type = ?, frequency = ?,
|
||||
specific_days = ?, specific_dates = ?, carry_over = ?, is_negative = ?,
|
||||
target_value = ?, archived_at = ?
|
||||
WHERE id = ?
|
||||
target_value = ?, archived_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query,
|
||||
@@ -151,6 +166,7 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
|
||||
habit.IsNegative,
|
||||
habit.TargetValue,
|
||||
habit.ArchivedAt,
|
||||
habit.UpdatedAt,
|
||||
habit.ID,
|
||||
)
|
||||
|
||||
@@ -174,7 +190,9 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -190,7 +208,9 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -203,9 +223,15 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
habits = append(habits, &habit)
|
||||
}
|
||||
@@ -217,9 +243,9 @@ func (r *HabitRepository) FindByUserID(ctx context.Context, userID string) ([]*e
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
@@ -252,9 +278,9 @@ func (r *HabitRepository) FindActiveByUserIDWithPagination(ctx context.Context,
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
@@ -272,7 +298,7 @@ func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var count int
|
||||
@@ -288,13 +314,16 @@ func (r *HabitRepository) FindByUserIDFiltered(ctx context.Context, userID strin
|
||||
baseQuery := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?`
|
||||
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
// Always exclude soft deleted
|
||||
conditions = append(conditions, "deleted_at IS NULL")
|
||||
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at IS NULL")
|
||||
}
|
||||
@@ -341,6 +370,9 @@ func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID stri
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
// Always exclude soft deleted
|
||||
conditions = append(conditions, "deleted_at IS NULL")
|
||||
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at IS NULL")
|
||||
}
|
||||
@@ -373,3 +405,141 @@ func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID stri
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
changes := &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}
|
||||
|
||||
// Get created and updated habits (not deleted)
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
AND updated_at > ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY updated_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query habits changes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&habit.ID,
|
||||
&habit.UserID,
|
||||
&habit.Name,
|
||||
&habit.Description,
|
||||
&habit.Type,
|
||||
&habit.Frequency,
|
||||
&specificDays,
|
||||
&specificDates,
|
||||
&habit.CarryOver,
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan habit: %w", err)
|
||||
}
|
||||
|
||||
if specificDays.Valid {
|
||||
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
||||
}
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
// Classify as created or updated based on when it was created
|
||||
if habit.CreatedAt.After(since) {
|
||||
changes.Created = append(changes.Created, &habit)
|
||||
} else {
|
||||
changes.Updated = append(changes.Updated, &habit)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating habits: %w", err)
|
||||
}
|
||||
|
||||
// Get deleted habits
|
||||
queryDeleted := `
|
||||
SELECT id
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
AND deleted_at IS NOT NULL
|
||||
AND deleted_at > ?
|
||||
ORDER BY deleted_at ASC
|
||||
`
|
||||
|
||||
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deleted habits: %w", err)
|
||||
}
|
||||
defer rowsDeleted.Close()
|
||||
|
||||
for rowsDeleted.Next() {
|
||||
var id string
|
||||
if err := rowsDeleted.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan deleted habit id: %w", err)
|
||||
}
|
||||
changes.Deleted = append(changes.Deleted, id)
|
||||
}
|
||||
|
||||
if err := rowsDeleted.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating deleted habits: %w", err)
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) SoftDelete(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habits
|
||||
SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to soft delete habit: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
)
|
||||
|
||||
func TestHabitRepository_GetChangesSince_EmptyWhenNoChanges(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear hábito inicial
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Initial Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Timestamp después de la creación
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
since := time.Now()
|
||||
|
||||
// No hay cambios después de 'since'
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Created) != 0 {
|
||||
t.Errorf("Expected 0 created habits, got %d", len(changes.Created))
|
||||
}
|
||||
if len(changes.Updated) != 0 {
|
||||
t.Errorf("Expected 0 updated habits, got %d", len(changes.Updated))
|
||||
}
|
||||
if len(changes.Deleted) != 0 {
|
||||
t.Errorf("Expected 0 deleted habits, got %d", len(changes.Deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_ReturnsCreatedHabits(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Timestamp de referencia
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Crear hábito DESPUÉS de 'since'
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"New Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Obtener cambios
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Created) != 1 {
|
||||
t.Fatalf("Expected 1 created habit, got %d", len(changes.Created))
|
||||
}
|
||||
|
||||
if changes.Created[0].Name != "New Habit" {
|
||||
t.Errorf("Expected habit name 'New Habit', got '%s'", changes.Created[0].Name)
|
||||
}
|
||||
|
||||
if changes.Created[0].ID != habit.ID {
|
||||
t.Errorf("Expected habit ID '%s', got '%s'", habit.ID, changes.Created[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_ReturnsUpdatedHabits(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear hábito inicial
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Original Name",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Timestamp de referencia
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Actualizar hábito DESPUÉS de 'since'
|
||||
habit.Name = "Updated Name"
|
||||
err = repo.Update(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
// Obtener cambios
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Updated) != 1 {
|
||||
t.Fatalf("Expected 1 updated habit, got %d", len(changes.Updated))
|
||||
}
|
||||
|
||||
if changes.Updated[0].Name != "Updated Name" {
|
||||
t.Errorf("Expected updated name 'Updated Name', got '%s'", changes.Updated[0].Name)
|
||||
}
|
||||
|
||||
if len(changes.Created) != 0 {
|
||||
t.Errorf("Expected 0 created habits (should be in Updated), got %d", len(changes.Created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_ReturnsDeletedHabits(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"To Delete",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
habitID := habit.ID
|
||||
|
||||
// Timestamp de referencia
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Soft delete DESPUÉS de 'since'
|
||||
err = repo.SoftDelete(ctx, habitID)
|
||||
if err != nil {
|
||||
t.Fatalf("SoftDelete failed: %v", err)
|
||||
}
|
||||
|
||||
// Obtener cambios
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Deleted) != 1 {
|
||||
t.Fatalf("Expected 1 deleted habit, got %d", len(changes.Deleted))
|
||||
}
|
||||
|
||||
if changes.Deleted[0] != habitID {
|
||||
t.Errorf("Expected deleted habit ID '%s', got '%s'", habitID, changes.Deleted[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_CombinedChanges(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear hábito inicial (antes de 'since')
|
||||
habitOld := entities.NewHabit(
|
||||
userID,
|
||||
"Old Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habitOld)
|
||||
|
||||
// Timestamp de referencia
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// DESPUÉS de 'since':
|
||||
// 1. Crear nuevo hábito
|
||||
habitNew := entities.NewHabit(
|
||||
userID,
|
||||
"New Habit",
|
||||
value_objects.HabitTypeCounter,
|
||||
value_objects.FrequencyWeekly,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
habitNew.SpecificDays = []int{1, 3, 5}
|
||||
repo.Create(ctx, habitNew)
|
||||
|
||||
// 2. Actualizar hábito existente
|
||||
habitOld.Name = "Old Habit Updated"
|
||||
repo.Update(ctx, habitOld)
|
||||
|
||||
// 3. Crear y eliminar otro hábito
|
||||
habitToDelete := entities.NewHabit(
|
||||
userID,
|
||||
"To Delete",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habitToDelete)
|
||||
repo.SoftDelete(ctx, habitToDelete.ID)
|
||||
|
||||
// Obtener cambios
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
// Verificar creados (habitNew, NO habitToDelete porque fue eliminado)
|
||||
if len(changes.Created) != 1 {
|
||||
t.Errorf("Expected 1 created habit, got %d", len(changes.Created))
|
||||
}
|
||||
if len(changes.Created) > 0 && changes.Created[0].Name != "New Habit" {
|
||||
t.Errorf("Expected created habit name 'New Habit', got '%s'", changes.Created[0].Name)
|
||||
}
|
||||
|
||||
// Verificar actualizados
|
||||
if len(changes.Updated) != 1 {
|
||||
t.Errorf("Expected 1 updated habit, got %d", len(changes.Updated))
|
||||
}
|
||||
if len(changes.Updated) > 0 && changes.Updated[0].Name != "Old Habit Updated" {
|
||||
t.Errorf("Expected updated habit name 'Old Habit Updated', got '%s'", changes.Updated[0].Name)
|
||||
}
|
||||
|
||||
// Verificar eliminados
|
||||
if len(changes.Deleted) != 1 {
|
||||
t.Errorf("Expected 1 deleted habit, got %d", len(changes.Deleted))
|
||||
}
|
||||
if len(changes.Deleted) > 0 && changes.Deleted[0] != habitToDelete.ID {
|
||||
t.Errorf("Expected deleted habit ID '%s', got '%s'", habitToDelete.ID, changes.Deleted[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_OnlyReturnsUserHabits(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Crear hábitos de diferentes usuarios
|
||||
habitUser1 := entities.NewHabit(
|
||||
"user-1",
|
||||
"User 1 Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habitUser1)
|
||||
|
||||
habitUser2 := entities.NewHabit(
|
||||
"user-2",
|
||||
"User 2 Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habitUser2)
|
||||
|
||||
// Obtener cambios solo de user-1
|
||||
changes, err := repo.GetChangesSince(ctx, "user-1", since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Created) != 1 {
|
||||
t.Fatalf("Expected 1 created habit for user-1, got %d", len(changes.Created))
|
||||
}
|
||||
|
||||
if changes.Created[0].UserID != "user-1" {
|
||||
t.Errorf("Expected user ID 'user-1', got '%s'", changes.Created[0].UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_SoftDelete_MarksAsDeleted(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
"user-123",
|
||||
"To Delete",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
|
||||
// Soft delete
|
||||
err := repo.SoftDelete(ctx, habit.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SoftDelete failed: %v", err)
|
||||
}
|
||||
|
||||
// El hábito NO debe aparecer en FindByID (porque está eliminado)
|
||||
found, err := repo.FindByID(ctx, habit.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected error when finding soft-deleted habit, got nil")
|
||||
}
|
||||
if found != nil {
|
||||
t.Error("Expected nil habit when soft-deleted, got habit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_SoftDelete_NotFoundError(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Intentar eliminar hábito inexistente
|
||||
err := repo.SoftDelete(ctx, "non-existent-id")
|
||||
if err == nil {
|
||||
t.Error("Expected error when deleting non-existent habit, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_SoftDelete_CannotDeleteTwice(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
"user-123",
|
||||
"To Delete",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
|
||||
// Primera eliminación
|
||||
err := repo.SoftDelete(ctx, habit.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("First SoftDelete failed: %v", err)
|
||||
}
|
||||
|
||||
// Segunda eliminación debe fallar
|
||||
err = repo.SoftDelete(ctx, habit.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected error when deleting already deleted habit, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_Update_UpdatesUpdatedAt(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
"user-123",
|
||||
"Original",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
|
||||
originalUpdatedAt := habit.UpdatedAt
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Actualizar
|
||||
habit.Name = "Updated"
|
||||
err := repo.Update(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
// Verificar que UpdatedAt cambió
|
||||
if !habit.UpdatedAt.After(originalUpdatedAt) {
|
||||
t.Errorf("Expected UpdatedAt to be updated, but it wasn't. Original: %v, Current: %v",
|
||||
originalUpdatedAt, habit.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_Create_SetsUpdatedAt(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
"user-123",
|
||||
"New Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Verificar que UpdatedAt está seteado
|
||||
if habit.UpdatedAt.IsZero() {
|
||||
t.Error("Expected UpdatedAt to be set, got zero value")
|
||||
}
|
||||
|
||||
// UpdatedAt debe ser igual a CreatedAt al crear
|
||||
if !habit.UpdatedAt.Equal(habit.CreatedAt) {
|
||||
t.Errorf("Expected UpdatedAt to equal CreatedAt on creation. UpdatedAt: %v, CreatedAt: %v",
|
||||
habit.UpdatedAt, habit.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_FindActiveByUserID_ExcludesSoftDeleted(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear 2 hábitos
|
||||
habit1 := entities.NewHabit(
|
||||
userID,
|
||||
"Active Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit1)
|
||||
|
||||
habit2 := entities.NewHabit(
|
||||
userID,
|
||||
"Deleted Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit2)
|
||||
|
||||
// Soft delete uno
|
||||
repo.SoftDelete(ctx, habit2.ID)
|
||||
|
||||
// FindActiveByUserID debe devolver solo el activo
|
||||
activeHabits, err := repo.FindActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if len(activeHabits) != 1 {
|
||||
t.Fatalf("Expected 1 active habit, got %d", len(activeHabits))
|
||||
}
|
||||
|
||||
if activeHabits[0].Name != "Active Habit" {
|
||||
t.Errorf("Expected 'Active Habit', got '%s'", activeHabits[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ func RunMigrations(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addSyncColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -85,6 +89,98 @@ func columnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func indexExists(db *sql.DB, indexName string) (bool, error) {
|
||||
query := "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?"
|
||||
var count int
|
||||
err := db.QueryRow(query, indexName).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func addSyncColumns(db *sql.DB) error {
|
||||
// Columns to add to habits table
|
||||
habitColumns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"updated_at", "ALTER TABLE habits ADD COLUMN updated_at DATETIME"},
|
||||
{"deleted_at", "ALTER TABLE habits ADD COLUMN deleted_at DATETIME"},
|
||||
}
|
||||
|
||||
for _, col := range habitColumns {
|
||||
exists, err := columnExists(db, "habits", col.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if _, err := db.Exec(col.definition); err != nil {
|
||||
return fmt.Errorf("failed to add column %s: %w", col.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize updated_at with created_at for existing records
|
||||
if _, err := db.Exec("UPDATE habits SET updated_at = created_at WHERE updated_at IS NULL"); err != nil {
|
||||
return fmt.Errorf("failed to initialize updated_at: %w", err)
|
||||
}
|
||||
|
||||
// Columns to add to habit_entries table
|
||||
entryColumns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"updated_at", "ALTER TABLE habit_entries ADD COLUMN updated_at DATETIME"},
|
||||
{"deleted_at", "ALTER TABLE habit_entries ADD COLUMN deleted_at DATETIME"},
|
||||
}
|
||||
|
||||
for _, col := range entryColumns {
|
||||
exists, err := columnExists(db, "habit_entries", col.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if _, err := db.Exec(col.definition); err != nil {
|
||||
return fmt.Errorf("failed to add column %s: %w", col.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize updated_at with completed_at for existing entries
|
||||
if _, err := db.Exec("UPDATE habit_entries SET updated_at = completed_at WHERE updated_at IS NULL"); err != nil {
|
||||
return fmt.Errorf("failed to initialize updated_at for entries: %w", err)
|
||||
}
|
||||
|
||||
// Create indexes for sync queries
|
||||
indexes := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"idx_habits_updated_at", "CREATE INDEX IF NOT EXISTS idx_habits_updated_at ON habits(user_id, updated_at)"},
|
||||
{"idx_habits_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habits_deleted_at ON habits(deleted_at)"},
|
||||
{"idx_habit_entries_updated_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_updated_at ON habit_entries(habit_id, updated_at)"},
|
||||
{"idx_habit_entries_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_deleted_at ON habit_entries(deleted_at)"},
|
||||
}
|
||||
|
||||
for _, idx := range indexes {
|
||||
exists, err := indexExists(db, idx.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if index %s exists: %w", idx.name, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if _, err := db.Exec(idx.definition); err != nil {
|
||||
return fmt.Errorf("failed to create index %s: %w", idx.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const createUsersTable = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
Reference in New Issue
Block a user