timetracker update
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package public
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
@@ -1,4 +1,4 @@
|
||||
package public
|
||||
package general
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/assets"
|
||||
@@ -1,4 +1,4 @@
|
||||
package public
|
||||
package general
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
@@ -1,4 +1,4 @@
|
||||
package public
|
||||
package general
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
@@ -1,4 +1,4 @@
|
||||
package public
|
||||
package general
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/api"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package langs
|
||||
package langsService
|
||||
|
||||
import (
|
||||
langs_repo "git.ma-al.com/goc_marek/timetracker/app/langs"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package view
|
||||
package responseErrors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
12
app/view/google_oauth.go
Normal file
12
app/view/google_oauth.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user