timetracker update

This commit is contained in:
Daniel Goc
2026-03-11 09:33:36 +01:00
parent bbf8a2c133
commit 9ef4bb219b
121 changed files with 4328 additions and 2231 deletions

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "timeTracker API",
"description": "Authentication and user management API",
"description": "Authentication, user management, and repository time tracking API",
"version": "1.0.0",
"contact": {
"name": "API Support",
@@ -22,15 +22,15 @@
},
{
"name": "Auth",
"description": "Authentication endpoints"
"description": "Authentication endpoints (under /api/v1/public/auth)"
},
{
"name": "Languages",
"description": "Language and translation endpoints"
},
{
"name": "Protected",
"description": "Protected routes requiring authentication"
"name": "Repo",
"description": "Repository time tracking data endpoints (under /api/v1/restricted/repo, requires authentication)"
},
{
"name": "Admin",
@@ -208,11 +208,11 @@
}
}
},
"/api/v1/auth/login": {
"/api/v1/public/auth/login": {
"post": {
"tags": ["Auth"],
"summary": "User login",
"description": "Authenticate a user with email and password",
"description": "Authenticate a user with email and password. Sets HTTPOnly cookies (access_token, refresh_token, is_authenticated) on success.",
"operationId": "login",
"requestBody": {
"required": true,
@@ -239,12 +239,12 @@
"schema": {
"type": "string"
},
"description": "HTTP-only cookies containing access and refresh tokens"
"description": "HTTPOnly cookies: access_token, refresh_token (opaque), is_authenticated (non-HTTPOnly flag)"
}
}
},
"400": {
"description": "Invalid request body",
"description": "Invalid request body or missing fields",
"content": {
"application/json": {
"schema": {
@@ -276,11 +276,11 @@
}
}
},
"/api/v1/auth/register": {
"/api/v1/public/auth/register": {
"post": {
"tags": ["Auth"],
"summary": "User registration",
"description": "Register a new user account",
"description": "Register a new user account. Sends a verification email. first_name and last_name are required.",
"operationId": "register",
"requestBody": {
"required": true,
@@ -310,7 +310,17 @@
}
},
"400": {
"description": "Invalid request or email already exists",
"description": "Invalid request, missing required fields, or invalid password format",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Email already exists",
"content": {
"application/json": {
"schema": {
@@ -322,11 +332,11 @@
}
}
},
"/api/v1/auth/complete-registration": {
"/api/v1/public/auth/complete-registration": {
"post": {
"tags": ["Auth"],
"summary": "Complete registration",
"description": "Complete registration after email verification",
"description": "Complete registration after email verification using the token sent by email. Sets auth cookies on success.",
"operationId": "completeRegistration",
"requestBody": {
"required": true,
@@ -347,10 +357,18 @@
"$ref": "#/components/schemas/AuthResponse"
}
}
},
"headers": {
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "HTTPOnly cookies: access_token, refresh_token, is_authenticated"
}
}
},
"400": {
"description": "Invalid token",
"description": "Invalid or expired token",
"content": {
"application/json": {
"schema": {
@@ -362,11 +380,11 @@
}
}
},
"/api/v1/auth/forgot-password": {
"/api/v1/public/auth/forgot-password": {
"post": {
"tags": ["Auth"],
"summary": "Request password reset",
"description": "Request a password reset email",
"description": "Request a password reset email. Always returns success to prevent email enumeration.",
"operationId": "forgotPassword",
"requestBody": {
"required": true,
@@ -404,7 +422,7 @@
}
},
"400": {
"description": "Invalid request",
"description": "Invalid request or missing email",
"content": {
"application/json": {
"schema": {
@@ -416,11 +434,11 @@
}
}
},
"/api/v1/auth/reset-password": {
"/api/v1/public/auth/reset-password": {
"post": {
"tags": ["Auth"],
"summary": "Reset password",
"description": "Reset password using reset token",
"description": "Reset password using reset token from email. Also revokes all existing refresh tokens for the user.",
"operationId": "resetPassword",
"requestBody": {
"required": true,
@@ -450,7 +468,7 @@
}
},
"400": {
"description": "Invalid or expired token",
"description": "Invalid or expired token, or invalid password format",
"content": {
"application/json": {
"schema": {
@@ -462,11 +480,11 @@
}
}
},
"/api/v1/auth/logout": {
"/api/v1/public/auth/logout": {
"post": {
"tags": ["Auth"],
"summary": "User logout",
"description": "Clear authentication cookies",
"description": "Revokes the refresh token from the database and clears all authentication cookies.",
"operationId": "logout",
"responses": {
"200": {
@@ -488,11 +506,11 @@
}
}
},
"/api/v1/auth/refresh": {
"/api/v1/public/auth/refresh": {
"post": {
"tags": ["Auth"],
"summary": "Refresh access token",
"description": "Get a new access token using refresh token",
"description": "Get a new access token using the refresh token. The refresh token is read from the HTTPOnly cookie first, then from the request body as fallback. Rotates the refresh token on success.",
"operationId": "refreshToken",
"requestBody": {
"content": {
@@ -502,7 +520,7 @@
"properties": {
"refresh_token": {
"type": "string",
"description": "Refresh token from login response"
"description": "Opaque refresh token (fallback if cookie not available)"
}
}
}
@@ -518,6 +536,14 @@
"$ref": "#/components/schemas/AuthResponse"
}
}
},
"headers": {
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "Rotated HTTPOnly cookies: access_token, refresh_token, is_authenticated"
}
}
},
"400": {
@@ -543,27 +569,25 @@
}
}
},
"/api/v1/protected/dashboard": {
"/api/v1/public/auth/me": {
"get": {
"tags": ["Protected"],
"summary": "Get dashboard data",
"description": "Protected route requiring authentication",
"tags": ["Auth"],
"summary": "Get current user",
"description": "Returns the currently authenticated user's session information. Requires authentication via cookie.",
"operationId": "getMe",
"security": [
{
"BearerAuth": []
"CookieAuth": []
}
],
"responses": {
"200": {
"description": "Dashboard data",
"description": "Current user info",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"user": {
"$ref": "#/components/schemas/UserSession"
}
@@ -585,28 +609,143 @@
}
}
},
"/api/v1/admin/users": {
"/api/v1/public/auth/google": {
"get": {
"tags": ["Admin"],
"summary": "Get all users",
"description": "Admin-only endpoint for user management",
"tags": ["Auth"],
"summary": "Google OAuth2 login",
"description": "Redirects the user to Google's OAuth2 consent page. Sets a short-lived oauth_state cookie for CSRF protection.",
"operationId": "googleLogin",
"responses": {
"302": {
"description": "Redirect to Google OAuth2 consent page",
"headers": {
"Location": {
"schema": {
"type": "string"
},
"description": "Google OAuth2 authorization URL"
},
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "HTTPOnly oauth_state cookie for CSRF protection (10 min expiry)"
}
}
},
"500": {
"description": "Failed to generate OAuth state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/google/callback": {
"get": {
"tags": ["Auth"],
"summary": "Google OAuth2 callback",
"description": "Handles the OAuth2 callback from Google. Validates state, exchanges code for tokens, creates or updates user, sets auth cookies, and redirects to the app.",
"operationId": "googleCallback",
"parameters": [
{
"name": "code",
"in": "query",
"description": "Authorization code from Google",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "state",
"in": "query",
"description": "State token for CSRF validation",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"302": {
"description": "Redirect to app after successful authentication",
"headers": {
"Location": {
"schema": {
"type": "string"
},
"description": "Redirect to /{lang} (user's preferred language)"
},
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "HTTPOnly cookies: access_token, refresh_token, is_authenticated"
}
}
},
"400": {
"description": "Invalid state (CSRF) or missing authorization code",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Google OAuth callback processing error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/restricted/repo/get-repos": {
"get": {
"tags": ["Repo"],
"summary": "Get accessible repositories",
"description": "Returns a list of repository IDs that the authenticated user has access to.",
"operationId": "getRepos",
"security": [
{
"BearerAuth": []
"CookieAuth": []
}
],
"responses": {
"200": {
"description": "List of users",
"description": "List of repository IDs",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
"type": "array",
"items": {
"type": "integer",
"format": "uint"
},
"example": [1, 2, 5]
}
}
}
},
"400": {
"description": "Invalid user session",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
@@ -620,9 +759,235 @@
}
}
}
}
}
}
},
"/api/v1/restricted/repo/get-years": {
"get": {
"tags": ["Repo"],
"summary": "Get available years for a repository",
"description": "Returns a list of years for which tracked time data exists in the given repository. User must have access to the repository.",
"operationId": "getYears",
"security": [
{
"CookieAuth": []
}
],
"parameters": [
{
"name": "repoID",
"in": "query",
"description": "Repository ID",
"required": true,
"schema": {
"type": "integer",
"format": "uint"
}
}
],
"responses": {
"200": {
"description": "List of years with tracked time data",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "integer",
"format": "uint"
},
"example": [2023, 2024, 2025]
}
}
}
},
"403": {
"description": "Admin access required",
"400": {
"description": "Invalid repoID parameter or user does not have access to the repository",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/restricted/repo/get-quarters": {
"get": {
"tags": ["Repo"],
"summary": "Get quarterly time data for a repository",
"description": "Returns time tracked per quarter for the given repository and year. All 4 quarters are returned; quarters with no data have time=0. User must have access to the repository.",
"operationId": "getQuarters",
"security": [
{
"CookieAuth": []
}
],
"parameters": [
{
"name": "repoID",
"in": "query",
"description": "Repository ID",
"required": true,
"schema": {
"type": "integer",
"format": "uint"
}
},
{
"name": "year",
"in": "query",
"description": "Year to retrieve quarterly data for",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"example": 2024
}
}
],
"responses": {
"200": {
"description": "Quarterly time data",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuarterData"
}
}
}
}
},
"400": {
"description": "Invalid repoID or year parameter, or user does not have access to the repository",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/restricted/repo/get-issues": {
"get": {
"tags": ["Repo"],
"summary": "Get issues with time summaries",
"description": "Returns a paginated list of issues with time tracking summaries for the given repository, year, and quarter. User must have access to the repository.",
"operationId": "getIssues",
"security": [
{
"CookieAuth": []
}
],
"parameters": [
{
"name": "repoID",
"in": "query",
"description": "Repository ID",
"required": true,
"schema": {
"type": "integer",
"format": "uint"
}
},
{
"name": "year",
"in": "query",
"description": "Year to filter issues by",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"example": 2024
}
},
{
"name": "quarter",
"in": "query",
"description": "Quarter number (1-4) to filter issues by",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"minimum": 1,
"maximum": 4,
"example": 2
}
},
{
"name": "page_number",
"in": "query",
"description": "Page number for pagination (1-based)",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"example": 1
}
},
{
"name": "elements_per_page",
"in": "query",
"description": "Number of items per page",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"example": 30
}
}
],
"responses": {
"200": {
"description": "Paginated list of issues with time summaries",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedIssues"
}
}
}
},
"400": {
"description": "Invalid parameters or user does not have access to the repository",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
@@ -675,7 +1040,13 @@
},
"RegisterRequest": {
"type": "object",
"required": ["email", "password", "confirm_password"],
"required": [
"email",
"password",
"confirm_password",
"first_name",
"last_name"
],
"properties": {
"email": {
"type": "string",
@@ -685,7 +1056,7 @@
"password": {
"type": "string",
"format": "password",
"description": "User's password (min 8 chars, uppercase, lowercase, digit)"
"description": "User's password (must meet complexity requirements: min 8 chars, uppercase, lowercase, digit)"
},
"confirm_password": {
"type": "string",
@@ -694,15 +1065,15 @@
},
"first_name": {
"type": "string",
"description": "User's first name"
"description": "User's first name (required)"
},
"last_name": {
"type": "string",
"description": "User's last name"
"description": "User's last name (required)"
},
"lang": {
"type": "string",
"description": "User's preferred language (e.g., 'en', 'pl', 'cs')"
"description": "User's preferred language ISO code (e.g., 'en', 'pl', 'cs')"
}
}
},
@@ -712,7 +1083,7 @@
"properties": {
"token": {
"type": "string",
"description": "Email verification token"
"description": "Email verification token received via email"
}
}
},
@@ -722,12 +1093,12 @@
"properties": {
"token": {
"type": "string",
"description": "Password reset token"
"description": "Password reset token received via email"
},
"password": {
"type": "string",
"format": "password",
"description": "New password"
"description": "New password (must meet complexity requirements)"
}
}
},
@@ -738,17 +1109,13 @@
"type": "string",
"description": "JWT access token"
},
"refresh_token": {
"type": "string",
"description": "JWT refresh token"
},
"token_type": {
"type": "string",
"example": "Bearer"
},
"expires_in": {
"type": "integer",
"description": "Token expiration in seconds"
"description": "Access token expiration in seconds"
},
"user": {
"$ref": "#/components/schemas/UserSession"
@@ -780,6 +1147,10 @@
},
"last_name": {
"type": "string"
},
"lang": {
"type": "string",
"description": "User's preferred language ISO code (e.g., 'en', 'pl', 'cs')"
}
}
},
@@ -788,7 +1159,7 @@
"properties": {
"error": {
"type": "string",
"description": "Error message"
"description": "Translated error message"
}
}
},
@@ -838,6 +1209,75 @@
}
}
},
"QuarterData": {
"type": "object",
"description": "Time tracked in a specific quarter",
"properties": {
"time": {
"type": "number",
"format": "double",
"description": "Total hours tracked in this quarter"
},
"quarter": {
"type": "string",
"description": "Quarter identifier in format YYYY_QN (e.g., '2024_Q1')",
"example": "2024_Q1"
}
}
},
"IssueTimeSummary": {
"type": "object",
"description": "Time tracking summary for a single issue",
"properties": {
"issue_id": {
"type": "integer",
"format": "uint",
"description": "Issue ID"
},
"issue_name": {
"type": "string",
"description": "Issue title/name"
},
"user_id": {
"type": "integer",
"format": "uint",
"description": "ID of the user who tracked time"
},
"initials": {
"type": "string",
"description": "Abbreviated initials of the user (e.g., 'J.D.')"
},
"created_date": {
"type": "string",
"format": "date",
"description": "Date when time was tracked"
},
"total_hours_spent": {
"type": "number",
"format": "double",
"description": "Total hours spent on this issue on the given date (rounded to 2 decimal places)"
}
}
},
"PaginatedIssues": {
"type": "object",
"description": "Paginated list of issue time summaries",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssueTimeSummary"
},
"description": "List of issue time summaries for the current page"
},
"items_count": {
"type": "integer",
"format": "uint",
"description": "Total number of items across all pages",
"example": 56
}
}
},
"SettingsResponse": {
"type": "object",
"properties": {
@@ -872,6 +1312,10 @@
"base_url": {
"type": "string",
"description": "Base URL of the application"
},
"password_regex": {
"type": "string",
"description": "Regular expression for password validation"
}
}
},
@@ -893,7 +1337,7 @@
"properties": {
"jwt_expiration": {
"type": "integer",
"description": "JWT token expiration in seconds"
"description": "JWT access token expiration in seconds"
},
"refresh_expiration": {
"type": "integer",
@@ -919,25 +1363,32 @@
"properties": {
"version": {
"type": "string",
"description": "Application version"
"description": "Application version (git tag or commit hash)"
},
"commit": {
"type": "string",
"description": "Git commit hash"
"description": "Short git commit hash"
},
"date": {
"build_date": {
"type": "string",
"description": "Build date"
"format": "date-time",
"description": "Build date in RFC3339 format"
}
}
}
},
"securitySchemes": {
"CookieAuth": {
"type": "apiKey",
"in": "cookie",
"name": "access_token",
"description": "HTTPOnly JWT access token cookie set during login, registration, or token refresh"
},
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "JWT token obtained from login response"
"description": "JWT token obtained from login response (alternative to cookie-based auth)"
}
}
}

View File

@@ -5,7 +5,7 @@ import (
"log"
"git.ma-al.com/goc_marek/timetracker/app/delivery/web"
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
"git.ma-al.com/goc_marek/timetracker/app/service/langsService"
"git.ma-al.com/goc_marek/timetracker/app/utils/version"
)
@@ -28,7 +28,7 @@ func main() {
}
// Load translations on startup
if err := langs.LangSrv.LoadTranslations(); err != nil {
if err := langsService.LangSrv.LoadTranslations(); err != nil {
log.Printf("Warning: Failed to load translations on startup: %v", err)
} else {
log.Println("Translations loaded successfully on startup")

View File

@@ -1,11 +0,0 @@
package handler
import (
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
"github.com/gofiber/fiber/v3"
)
// AuthHandlerRoutes registers all auth routes
func AuthHandlerRoutes(r fiber.Router) fiber.Router {
return public.AuthHandlerRoutes(r)
}

View File

@@ -1,11 +0,0 @@
package handler
import (
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
"github.com/gofiber/fiber/v3"
)
// AuthHandlerRoutes registers all auth routes
func RepoHandlerRoutes(r fiber.Router) fiber.Router {
return public.RepoHandlerRoutes(r)
}

View File

@@ -4,14 +4,14 @@ import (
"strconv"
"strings"
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
"git.ma-al.com/goc_marek/timetracker/app/service/langsService"
"github.com/gofiber/fiber/v3"
)
// LanguageMiddleware discovers client's language and stores it in context
// Priority: Query param > Cookie > Accept-Language header > Default language
func LanguageMiddleware() fiber.Handler {
langService := langs.LangSrv
langService := langsService.LangSrv
return func(c fiber.Ctx) error {
var langID uint

View File

@@ -1,28 +1,27 @@
package public
package api
import (
"strconv"
"git.ma-al.com/goc_marek/timetracker/app/config"
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
"git.ma-al.com/goc_marek/timetracker/app/service/langsService"
"github.com/gofiber/fiber/v3"
)
type LangHandler struct {
service langs.LangService
service langsService.LangService
}
func NewLangHandler() *LangHandler {
return &LangHandler{
service: *langs.LangSrv,
service: *langsService.LangSrv,
}
}
func (h *LangHandler) InitLanguage(api fiber.Router, cfg *config.Config) {
api.Get("langs", h.GetLanguages)
api.Get("translations", h.GetTranslations)
api.Get("translations/reload", h.ReloadTranslations)
api.Get("/langs", h.GetLanguages)
api.Get("/translations", h.GetTranslations)
api.Get("/translations/reload", h.ReloadTranslations)
}
func (h *LangHandler) GetLanguages(c fiber.Ctx) error {

View File

@@ -9,7 +9,7 @@ import (
"git.ma-al.com/goc_marek/timetracker/app/model"
"git.ma-al.com/goc_marek/timetracker/app/service/authService"
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
"git.ma-al.com/goc_marek/timetracker/app/view"
"git.ma-al.com/goc_marek/timetracker/app/utils/responseErrors"
"github.com/gofiber/fiber/v3"
)
@@ -56,22 +56,22 @@ func (h *AuthHandler) Login(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
// Validate required fields
if req.Email == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrEmailPasswordRequired),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrEmailPasswordRequired),
})
}
// Attempt login
response, rawRefreshToken, err := h.authService.Login(&req)
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -159,14 +159,14 @@ func (h *AuthHandler) ForgotPassword(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
// Validate email
if req.Email == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrEmailRequired),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrEmailRequired),
})
}
@@ -187,22 +187,22 @@ func (h *AuthHandler) ResetPassword(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
// Validate required fields
if req.Token == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrTokenPasswordRequired),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrTokenPasswordRequired),
})
}
// Reset password (also revokes all refresh tokens for the user)
err := h.authService.ResetPassword(req.Token, req.Password)
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -242,7 +242,7 @@ func (h *AuthHandler) RefreshToken(c fiber.Ctx) error {
if rawRefreshToken == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrRefreshTokenRequired),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrRefreshTokenRequired),
})
}
@@ -250,8 +250,8 @@ func (h *AuthHandler) RefreshToken(c fiber.Ctx) error {
if err != nil {
// If refresh token is invalid/expired, clear cookies
h.clearAuthCookies(c)
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -266,7 +266,7 @@ func (h *AuthHandler) Me(c fiber.Ctx) error {
user := c.Locals("user")
if user == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrNotAuthenticated),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrNotAuthenticated),
})
}
@@ -281,21 +281,21 @@ func (h *AuthHandler) Register(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
// Validate required fields
if req.FirstName == "" || req.LastName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrFirstLastNameRequired),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrFirstLastNameRequired),
})
}
// Validate required fields
if req.Email == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrEmailPasswordRequired),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrEmailPasswordRequired),
})
}
@@ -303,8 +303,8 @@ func (h *AuthHandler) Register(c fiber.Ctx) error {
err := h.authService.Register(&req)
if err != nil {
log.Printf("Register error: %v", err)
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -319,22 +319,22 @@ func (h *AuthHandler) CompleteRegistration(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
// Validate required fields
if req.Token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrTokenRequired),
"error": responseErrors.GetErrorCode(c, responseErrors.ErrTokenRequired),
})
}
// Attempt to complete registration
response, rawRefreshToken, err := h.authService.CompleteRegistration(&req)
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -398,8 +398,8 @@ func (h *AuthHandler) GoogleCallback(c fiber.Ctx) error {
response, rawRefreshToken, err := h.authService.HandleGoogleCallback(code)
if err != nil {
log.Printf("Google OAuth callback error: %v", err)
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -412,5 +412,5 @@ func (h *AuthHandler) GoogleCallback(c fiber.Ctx) error {
if lang == "" {
lang = "en"
}
return c.Redirect().To(h.config.App.BaseURL + "/" + lang + "/chart")
return c.Redirect().To(h.config.App.BaseURL + "/" + lang)
}

View File

@@ -1,4 +1,4 @@
package public
package restricted
import (
"strconv"
@@ -6,7 +6,7 @@ import (
"git.ma-al.com/goc_marek/timetracker/app/config"
"git.ma-al.com/goc_marek/timetracker/app/service/repoService"
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
"git.ma-al.com/goc_marek/timetracker/app/view"
"git.ma-al.com/goc_marek/timetracker/app/utils/responseErrors"
"github.com/gofiber/fiber/v3"
)
@@ -41,15 +41,15 @@ func RepoHandlerRoutes(r fiber.Router) fiber.Router {
func (h *RepoHandler) GetRepoIDs(c fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
response, err := h.repoService.GetRepositoriesForUser(userID)
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -59,23 +59,23 @@ func (h *RepoHandler) GetRepoIDs(c fiber.Ctx) error {
func (h *RepoHandler) GetYears(c fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
repoID_attribute := c.Query("repoID")
repoID, err := strconv.Atoi(repoID_attribute)
if err != nil {
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadRepoIDAttribute)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadRepoIDAttribute),
})
}
response, err := h.repoService.GetYearsForUser(userID, uint(repoID))
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -85,31 +85,31 @@ func (h *RepoHandler) GetYears(c fiber.Ctx) error {
func (h *RepoHandler) GetQuarters(c fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
repoID_attribute := c.Query("repoID")
repoID, err := strconv.Atoi(repoID_attribute)
if err != nil {
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadRepoIDAttribute)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadRepoIDAttribute),
})
}
year_attribute := c.Query("year")
year, err := strconv.Atoi(year_attribute)
if err != nil {
return c.Status(view.GetErrorStatus(view.ErrBadYearAttribute)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrBadYearAttribute),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadYearAttribute)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadYearAttribute),
})
}
response, err := h.repoService.GetQuartersForUser(userID, uint(repoID), uint(year))
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}
@@ -119,48 +119,48 @@ func (h *RepoHandler) GetQuarters(c fiber.Ctx) error {
func (h *RepoHandler) GetIssues(c fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrInvalidBody),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
})
}
repoID_attribute := c.Query("repoID")
repoID, err := strconv.Atoi(repoID_attribute)
if err != nil {
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadRepoIDAttribute)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadRepoIDAttribute),
})
}
year_attribute := c.Query("year")
year, err := strconv.Atoi(year_attribute)
if err != nil {
return c.Status(view.GetErrorStatus(view.ErrBadYearAttribute)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrBadYearAttribute),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadYearAttribute)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadYearAttribute),
})
}
quarter_attribute := c.Query("quarter")
quarter, err := strconv.Atoi(quarter_attribute)
if err != nil {
return c.Status(view.GetErrorStatus(view.ErrBadQuarterAttribute)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrBadQuarterAttribute),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadQuarterAttribute)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadQuarterAttribute),
})
}
page_number_attribute := c.Query("page_number")
page_number, err := strconv.Atoi(page_number_attribute)
if err != nil {
return c.Status(view.GetErrorStatus(view.ErrBadPaging)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrBadPaging),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadPaging)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadPaging),
})
}
elements_per_page_attribute := c.Query("quarter")
elements_per_page_attribute := c.Query("elements_per_page")
elements_per_page, err := strconv.Atoi(elements_per_page_attribute)
if err != nil {
return c.Status(view.GetErrorStatus(view.ErrBadPaging)).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrBadPaging),
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadPaging)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadPaging),
})
}
@@ -170,8 +170,8 @@ func (h *RepoHandler) GetIssues(c fiber.Ctx) error {
response, err := h.repoService.GetIssuesForUser(userID, uint(repoID), uint(year), uint(quarter), paging)
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.GetErrorCode(c, err),
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
"error": responseErrors.GetErrorCode(c, err),
})
}

View File

@@ -1,4 +1,4 @@
package public
package api
import (
"git.ma-al.com/goc_marek/timetracker/app/config"

View File

@@ -1,4 +1,4 @@
package public
package general
import (
"git.ma-al.com/goc_marek/timetracker/assets"

View File

@@ -1,4 +1,4 @@
package public
package general
import (
"git.ma-al.com/goc_marek/timetracker/app/config"

View File

@@ -1,4 +1,4 @@
package public
package general
import (
"git.ma-al.com/goc_marek/timetracker/app/config"

View File

@@ -1,4 +1,4 @@
package public
package general
import (
"git.ma-al.com/goc_marek/timetracker/app/api"

View File

@@ -10,9 +10,11 @@ import (
"time"
"git.ma-al.com/goc_marek/timetracker/app/config"
"git.ma-al.com/goc_marek/timetracker/app/delivery/handler"
"git.ma-al.com/goc_marek/timetracker/app/delivery/middleware"
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/api"
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/api/public"
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/api/restricted"
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/general"
// "github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v3"
@@ -23,9 +25,11 @@ import (
// Server represents the web server
type Server struct {
app *fiber.App
cfg *config.Config
api fiber.Router
app *fiber.App
cfg *config.Config
api fiber.Router
public fiber.Router
restricted fiber.Router
}
// App returns the fiber app
@@ -61,54 +65,57 @@ func (s *Server) Setup() error {
s.app.Use(middleware.LanguageMiddleware())
// initialize healthcheck
public.InitHealth(s.App(), s.Cfg())
general.InitHealth(s.App(), s.Cfg())
// serve favicon
public.Favicon(s.app, s.cfg)
general.Favicon(s.app, s.cfg)
// initialize swagger endpoints
general.InitSwagger(s.App())
// API routes
s.api = s.app.Group("/api/v1")
s.public = s.api.Group("/public")
s.restricted = s.api.Group("/restricted")
s.restricted.Use(middleware.AuthMiddleware())
// initialize swagger endpoints
public.InitSwagger(s.App())
// initialize language endpoints (general)
api.NewLangHandler().InitLanguage(s.api, s.cfg)
// Settings endpoint (general)
api.NewSettingsHandler().InitSettings(s.api, s.cfg)
// Auth routes (public)
auth := s.api.Group("/auth")
handler.AuthHandlerRoutes(auth)
auth := s.public.Group("/auth")
public.AuthHandlerRoutes(auth)
// Repo routes (public)
repo := s.api.Group("/repo")
repo.Use(middleware.AuthMiddleware())
handler.RepoHandlerRoutes(repo)
// Repo routes (restricted)
repo := s.restricted.Group("/repo")
restricted.RepoHandlerRoutes(repo)
// Protected routes example
protected := s.api.Group("/restricted")
protected.Use(middleware.AuthMiddleware())
protected.Get("/dashboard", func(c fiber.Ctx) error {
user := middleware.GetUser(c)
return c.JSON(fiber.Map{
"message": "Welcome to the protected area",
"user": user,
})
})
// // Restricted routes example
// restricted := s.api.Group("/restricted")
// restricted.Use(middleware.AuthMiddleware())
// restricted.Get("/dashboard", func(c fiber.Ctx) error {
// user := middleware.GetUser(c)
// return c.JSON(fiber.Map{
// "message": "Welcome to the protected area",
// "user": user,
// })
// })
// Admin routes example
admin := s.api.Group("/admin")
admin.Use(middleware.AuthMiddleware())
admin.Use(middleware.RequireAdmin())
admin.Get("/users", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{
"message": "Admin area - user management",
})
})
public.NewLangHandler().InitLanguage(s.api, s.cfg)
// Settings endpoint
public.NewSettingsHandler().InitSettings(s.api, s.cfg)
// // Admin routes example
// admin := s.api.Group("/admin")
// admin.Use(middleware.AuthMiddleware())
// admin.Use(middleware.RequireAdmin())
// admin.Get("/users", func(c fiber.Ctx) error {
// return c.JSON(fiber.Map{
// "message": "Admin area - user management",
// })
// })
// keep this at the end because its wilderange
public.InitBo(s.App())
general.InitBo(s.App())
return nil
}

View File

@@ -1,22 +0,0 @@
package model
// LoginRequest represents the login form data
type DataRequest struct {
RepoID uint `json:"repoid" form:"repoid"`
Step uint `json:"step" form:"step"`
}
type PageMeta struct {
Title string
Description string
}
type QuarterData struct {
Time float64 `json:"time"`
Quarter string `json:"quarter"`
}
type DayData struct {
Date string `json:"date"`
Time float64 `json:"time"`
}

View File

@@ -1,15 +1,35 @@
package view
package model
import (
"time"
"git.ma-al.com/goc_marek/timetracker/app/model"
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
)
// LoginRequest represents the login form data
type DataRequest struct {
RepoID uint `json:"repoid" form:"repoid"`
Step uint `json:"step" form:"step"`
}
type PageMeta struct {
Title string
Description string
}
type QuarterData struct {
Time float64 `json:"time"`
Quarter string `json:"quarter"`
}
type DayData struct {
Date string `json:"date"`
Time float64 `json:"time"`
}
type RepositoryChartData struct {
Years []uint
Quarters []model.QuarterData
Quarters []QuarterData
QuartersJSON string
Year uint
}
@@ -20,7 +40,7 @@ type TimeTrackedData struct {
Quarter uint
Step string
TotalTime float64
DailyData []model.DayData
DailyData []DayData
DailyDataJSON string
Years []uint
IssueSummaries *pagination.Found[IssueTimeSummary]
@@ -29,8 +49,6 @@ type TimeTrackedData struct {
type IssueTimeSummary struct {
IssueID uint `gorm:"column:issue_id"`
IssueName string `gorm:"column:issue_name"`
UserID uint `gorm:"column:user_id"`
Initials string `gorm:"column:initials"`
CreatedDate time.Time `gorm:"column:created_date"`
CreatedDate time.Time `gorm:"column:issue_created_at"`
TotalHoursSpent float64 `gorm:"column:total_hours_spent"`
}

View File

@@ -1,61 +0,0 @@
package model
import "encoding/json"
type Repository struct {
ID int64 `db:"id"`
OwnerID *int64 `db:"owner_id"`
OwnerName *string `db:"owner_name"`
LowerName string `db:"lower_name"`
Name string `db:"name"`
Description *string `db:"description"`
Website *string `db:"website"`
OriginalServiceType *int `db:"original_service_type"`
OriginalURL *string `db:"original_url"`
DefaultBranch *string `db:"default_branch"`
DefaultWikiBranch *string `db:"default_wiki_branch"`
NumWatches *int `db:"num_watches"`
NumStars *int `db:"num_stars"`
NumForks *int `db:"num_forks"`
NumIssues *int `db:"num_issues"`
NumClosedIssues *int `db:"num_closed_issues"`
NumPulls *int `db:"num_pulls"`
NumClosedPulls *int `db:"num_closed_pulls"`
NumMilestones int `db:"num_milestones"`
NumClosedMilestones int `db:"num_closed_milestones"`
NumProjects int `db:"num_projects"`
NumClosedProjects int `db:"num_closed_projects"`
NumActionRuns int `db:"num_action_runs"`
NumClosedActionRuns int `db:"num_closed_action_runs"`
IsPrivate *bool `db:"is_private"`
IsEmpty *bool `db:"is_empty"`
IsArchived *bool `db:"is_archived"`
IsMirror *bool `db:"is_mirror"`
Status int `db:"status"`
IsFork bool `db:"is_fork"`
ForkID *int64 `db:"fork_id"`
IsTemplate bool `db:"is_template"`
TemplateID *int64 `db:"template_id"`
Size int64 `db:"size"`
GitSize int64 `db:"git_size"`
LFSSize int64 `db:"lfs_size"`
IsFsckEnabled bool `db:"is_fsck_enabled"`
CloseIssuesViaCommitAnyBranch bool `db:"close_issues_via_commit_in_any_branch"`
Topics json.RawMessage `db:"topics"`
ObjectFormatName string `db:"object_format_name"`
TrustModel *int `db:"trust_model"`
Avatar *string `db:"avatar"`
CreatedUnix *int64 `db:"created_unix"`
UpdatedUnix *int64 `db:"updated_unix"`
ArchivedUnix int64 `db:"archived_unix"`
}

View File

@@ -13,7 +13,7 @@ import (
"git.ma-al.com/goc_marek/timetracker/app/model"
"git.ma-al.com/goc_marek/timetracker/app/service/emailService"
constdata "git.ma-al.com/goc_marek/timetracker/app/utils/const_data"
"git.ma-al.com/goc_marek/timetracker/app/view"
"git.ma-al.com/goc_marek/timetracker/app/utils/responseErrors"
"github.com/dlclark/regexp2"
"github.com/golang-jwt/jwt/v5"
@@ -60,23 +60,23 @@ func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, strin
// Find user by email
if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", view.ErrInvalidCredentials
return nil, "", responseErrors.ErrInvalidCredentials
}
return nil, "", fmt.Errorf("database error: %w", err)
}
// Check if user is active
if !user.IsActive {
return nil, "", view.ErrUserInactive
return nil, "", responseErrors.ErrUserInactive
}
// Check if email is verified
if !user.EmailVerified {
return nil, "", view.ErrEmailNotVerified
return nil, "", responseErrors.ErrEmailNotVerified
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return nil, "", view.ErrInvalidCredentials
return nil, "", responseErrors.ErrInvalidCredentials
}
// Update last login time
@@ -109,17 +109,17 @@ func (s *AuthService) Register(req *model.RegisterRequest) error {
// Check if email already exists
var existingUser model.Customer
if err := s.db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
return view.ErrEmailExists
return responseErrors.ErrEmailExists
}
// Validate passwords match
if req.Password != req.ConfirmPassword {
return view.ErrPasswordsDoNotMatch
return responseErrors.ErrPasswordsDoNotMatch
}
// Validate password strength
if err := validatePassword(req.Password); err != nil {
return view.ErrInvalidPassword
return responseErrors.ErrInvalidPassword
}
// Hash password
@@ -176,14 +176,14 @@ func (s *AuthService) CompleteRegistration(req *model.CompleteRegistrationReques
var user model.Customer
if err := s.db.Where("email_verification_token = ?", req.Token).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", view.ErrInvalidVerificationToken
return nil, "", responseErrors.ErrInvalidVerificationToken
}
return nil, "", fmt.Errorf("database error: %w", err)
}
// Check if token is expired
if user.EmailVerificationExpires != nil && user.EmailVerificationExpires.Before(time.Now()) {
return nil, "", view.ErrVerificationTokenExpired
return nil, "", responseErrors.ErrVerificationTokenExpired
}
// Update user - activate account and mark email as verified
@@ -283,19 +283,19 @@ func (s *AuthService) ResetPassword(token, newPassword string) error {
var user model.Customer
if err := s.db.Where("password_reset_token = ?", token).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return view.ErrInvalidResetToken
return responseErrors.ErrInvalidResetToken
}
return fmt.Errorf("database error: %w", err)
}
// Check if token is expired
if user.PasswordResetExpires == nil || user.PasswordResetExpires.Before(time.Now()) {
return view.ErrResetTokenExpired
return responseErrors.ErrResetTokenExpired
}
// Validate new password
if err := validatePassword(newPassword); err != nil {
return view.ErrInvalidPassword
return responseErrors.ErrInvalidPassword
}
// Hash new password
@@ -330,14 +330,14 @@ func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, view.ErrTokenExpired
return nil, responseErrors.ErrTokenExpired
}
return nil, view.ErrInvalidToken
return nil, responseErrors.ErrInvalidToken
}
claims, ok := token.Claims.(*JWTClaims)
if !ok || !token.Valid {
return nil, view.ErrInvalidToken
return nil, responseErrors.ErrInvalidToken
}
return claims, nil
@@ -352,7 +352,7 @@ func (s *AuthService) RefreshToken(rawToken string) (*model.AuthResponse, string
var rt model.RefreshToken
if err := s.db.Where("token_hash = ?", tokenHash).First(&rt).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", view.ErrInvalidToken
return nil, "", responseErrors.ErrInvalidToken
}
return nil, "", fmt.Errorf("database error: %w", err)
}
@@ -361,21 +361,21 @@ func (s *AuthService) RefreshToken(rawToken string) (*model.AuthResponse, string
if rt.ExpiresAt.Before(time.Now()) {
// Clean up expired token
s.db.Delete(&rt)
return nil, "", view.ErrTokenExpired
return nil, "", responseErrors.ErrTokenExpired
}
// Get user from database
var user model.Customer
if err := s.db.First(&user, rt.CustomerID).Error; err != nil {
return nil, "", view.ErrUserNotFound
return nil, "", responseErrors.ErrUserNotFound
}
if !user.IsActive {
return nil, "", view.ErrUserInactive
return nil, "", responseErrors.ErrUserInactive
}
if !user.EmailVerified {
return nil, "", view.ErrEmailNotVerified
return nil, "", responseErrors.ErrEmailNotVerified
}
// Delete the old refresh token (rotation: one-time use)
@@ -420,7 +420,7 @@ func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) {
var user model.Customer
if err := s.db.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, view.ErrUserNotFound
return nil, responseErrors.ErrUserNotFound
}
return nil, fmt.Errorf("database error: %w", err)
}
@@ -432,7 +432,7 @@ func (s *AuthService) GetUserByEmail(email string) (*model.Customer, error) {
var user model.Customer
if err := s.db.Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, view.ErrUserNotFound
return nil, responseErrors.ErrUserNotFound
}
return nil, fmt.Errorf("database error: %w", err)
}

View File

@@ -12,6 +12,7 @@ import (
"git.ma-al.com/goc_marek/timetracker/app/config"
"git.ma-al.com/goc_marek/timetracker/app/model"
"git.ma-al.com/goc_marek/timetracker/app/utils/responseErrors"
"git.ma-al.com/goc_marek/timetracker/app/view"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
@@ -19,17 +20,6 @@ import (
const googleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
// GoogleUserInfo represents the user info returned by Google
type GoogleUserInfo struct {
ID string `json:"id"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
}
// googleOAuthConfig returns the OAuth2 config for Google
func googleOAuthConfig() *oauth2.Config {
cfg := config.Get().OAuth.Google
@@ -81,7 +71,7 @@ func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, st
}
if !userInfo.VerifiedEmail {
return nil, "", view.ErrEmailNotVerified
return nil, "", responseErrors.ErrEmailNotVerified
}
// Find or create user
@@ -117,7 +107,7 @@ func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, st
// findOrCreateGoogleUser finds an existing user by Google provider ID or email,
// or creates a new one.
func (s *AuthService) findOrCreateGoogleUser(info *GoogleUserInfo) (*model.Customer, error) {
func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model.Customer, error) {
var user model.Customer
// Try to find by provider + provider_id
@@ -183,7 +173,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *GoogleUserInfo) (*model.Custo
}
// fetchGoogleUserInfo fetches user info from Google using the provided HTTP client
func fetchGoogleUserInfo(client *http.Client) (*GoogleUserInfo, error) {
func fetchGoogleUserInfo(client *http.Client) (*view.GoogleUserInfo, error) {
resp, err := client.Get(googleUserInfoURL)
if err != nil {
return nil, err
@@ -195,7 +185,7 @@ func fetchGoogleUserInfo(client *http.Client) (*GoogleUserInfo, error) {
return nil, err
}
var userInfo GoogleUserInfo
var userInfo view.GoogleUserInfo
if err := json.Unmarshal(body, &userInfo); err != nil {
return nil, err
}

View File

@@ -8,7 +8,7 @@ import (
"strings"
"git.ma-al.com/goc_marek/timetracker/app/config"
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
"git.ma-al.com/goc_marek/timetracker/app/service/langsService"
"git.ma-al.com/goc_marek/timetracker/app/templ/emails"
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
"git.ma-al.com/goc_marek/timetracker/app/view"
@@ -32,7 +32,7 @@ func getLangID(isoCode string) uint {
isoCode = "en"
}
lang, err := langs.LangSrv.GetLanguageByISOCode(isoCode)
lang, err := langsService.LangSrv.GetLanguageByISOCode(isoCode)
if err != nil || lang == nil {
return 1 // Default to English (ID 1)
}

View File

@@ -1,4 +1,4 @@
package langs
package langsService
import (
langs_repo "git.ma-al.com/goc_marek/timetracker/app/langs"

View File

@@ -7,7 +7,7 @@ import (
"git.ma-al.com/goc_marek/timetracker/app/db"
"git.ma-al.com/goc_marek/timetracker/app/model"
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
"git.ma-al.com/goc_marek/timetracker/app/view"
"git.ma-al.com/goc_marek/timetracker/app/utils/responseErrors"
"gorm.io/gorm"
)
@@ -45,7 +45,7 @@ func (s *RepoService) UserHasAccessToRepo(userID uint, repoID uint) (bool, error
}
if !slices.Contains(repositories, repoID) {
return false, view.ErrInvalidRepoID
return false, responseErrors.ErrInvalidRepoID
}
return true, nil
@@ -147,140 +147,19 @@ func (s *RepoService) GetQuarters(repo uint, year uint) ([]model.QuarterData, er
Find(&quarters).
Error
if err != nil {
fmt.Printf("err: %v\n", err)
return nil, err
}
return quarters, nil
}
func (s *RepoService) GetTotalTimeForQuarter(repo uint, year uint, quarter uint) (float64, error) {
var total float64
query := `
SELECT COALESCE(SUM(tt.time) / 3600, 0) AS total_time
FROM tracked_time tt
JOIN issue i ON i.id = tt.issue_id
WHERE i.repo_id = ?
AND EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
AND EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) = ?
AND tt.deleted = false
`
err := db.Get().Raw(query, repo, year, quarter).Row().Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
func (s *RepoService) GetTimeTracked(repo uint, year uint, quarter uint, step string) ([]model.DayData, error) {
var days []model.DayData
// Calculate quarter start and end dates
quarterStartMonth := (quarter-1)*3 + 1
quarterStart := fmt.Sprintf("%d-%02d-01", year, quarterStartMonth)
var quarterEnd string
switch quarter {
case 1:
quarterEnd = fmt.Sprintf("%d-03-31", year)
case 2:
quarterEnd = fmt.Sprintf("%d-06-30", year)
case 3:
quarterEnd = fmt.Sprintf("%d-09-30", year)
default:
quarterEnd = fmt.Sprintf("%d-12-31", year)
}
var bucketExpr string
var seriesInterval string
var seriesStart string
var seriesEnd string
switch step {
case "day":
bucketExpr = "DATE(to_timestamp(tt.created_unix))"
seriesInterval = "1 day"
seriesStart = "p.start_date"
seriesEnd = "p.end_date"
case "week":
bucketExpr = `
(p.start_date +
((DATE(to_timestamp(tt.created_unix)) - p.start_date) / 7) * 7
)::date`
seriesInterval = "7 days"
seriesStart = "p.start_date"
seriesEnd = "p.end_date"
case "month":
bucketExpr = "date_trunc('month', to_timestamp(tt.created_unix))::date"
seriesInterval = "1 month"
seriesStart = "date_trunc('month', p.start_date)"
seriesEnd = "date_trunc('month', p.end_date)"
}
query := fmt.Sprintf(`
WITH params AS (
SELECT ?::date AS start_date, ?::date AS end_date
),
date_range AS (
SELECT generate_series(
%s,
%s,
interval '%s'
)::date AS date
FROM params p
),
data AS (
SELECT
%s AS date,
SUM(tt.time) / 3600 AS time
FROM tracked_time tt
JOIN issue i ON i.id = tt.issue_id
CROSS JOIN params p
WHERE i.repo_id = ?
AND to_timestamp(tt.created_unix) >= p.start_date
AND to_timestamp(tt.created_unix) < p.end_date + interval '1 day'
AND tt.deleted = false
GROUP BY 1
)
SELECT
TO_CHAR(dr.date, 'YYYY-MM-DD') AS date,
COALESCE(d.time, 0) AS time
FROM date_range dr
LEFT JOIN data d ON d.date = dr.date
ORDER BY dr.date
`, seriesStart, seriesEnd, seriesInterval, bucketExpr)
err := db.Get().
Raw(query, quarterStart, quarterEnd, repo).
Scan(&days).Error
if err != nil {
return nil, err
}
return days, nil
}
func (s *RepoService) GetRepoData(repoIds []uint) ([]model.Repository, error) {
var repos []model.Repository
err := db.Get().Model(model.Repository{}).Where("id = ?", repoIds).Find(&repos).Error
if err != nil {
return nil, err
}
return repos, nil
}
func (s *RepoService) GetIssuesForUser(
userID uint,
repoID uint,
year uint,
quarter uint,
p pagination.Paging,
) (*pagination.Found[view.IssueTimeSummary], error) {
) (*pagination.Found[model.IssueTimeSummary], error) {
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
return nil, err
}
@@ -293,21 +172,14 @@ func (s *RepoService) GetIssues(
year uint,
quarter uint,
p pagination.Paging,
) (*pagination.Found[view.IssueTimeSummary], error) {
) (*pagination.Found[model.IssueTimeSummary], error) {
query := db.Get().Debug().
query := db.Get().
Table("issue i").
Select(`
i.id AS issue_id,
i.name AS issue_name,
u.id AS user_id,
upper(
regexp_replace(
regexp_replace(u.full_name, '(\y\w)\w*', '\1', 'g'),
'(\w)', '\1.', 'g'
)
) AS initials,
to_timestamp(tt.created_unix)::date AS created_date,
to_timestamp(i.created_unix) AS issue_created_at,
ROUND(SUM(tt.time) / 3600.0, 2) AS total_hours_spent
`).
Joins(`JOIN tracked_time tt ON tt.issue_id = i.id`).
@@ -321,12 +193,11 @@ func (s *RepoService) GetIssues(
i.id,
i.name,
u.id,
u.full_name,
created_date
u.full_name
`).
Order("created_date")
Order("i.created_unix")
result, err := pagination.Paginate[view.IssueTimeSummary](p, query)
result, err := pagination.Paginate[model.IssueTimeSummary](p, query)
if err != nil {
return nil, err
}

View File

@@ -1,9 +1,6 @@
package pagination
import (
"strconv"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
@@ -55,9 +52,3 @@ func Paginate[T any](paging Paging, stmt *gorm.DB) (Found[T], error) {
Count: uint(count),
}, err
}
func ParsePagination(c *fiber.Ctx) Paging {
pageNum, _ := strconv.ParseInt((*c).Query("p", "1"), 10, 64)
pageSize, _ := strconv.ParseInt((*c).Query("elems", "10"), 10, 64)
return Paging{Page: uint(pageNum), Elements: uint(pageSize)}
}

View File

@@ -1,4 +1,4 @@
package view
package responseErrors
import (
"errors"

12
app/view/google_oauth.go Normal file
View File

@@ -0,0 +1,12 @@
package view
// GoogleUserInfo represents the user info returned by Google
type GoogleUserInfo struct {
ID string `json:"id"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
}