diff --git a/.gitignore b/.gitignore index aaadf73..f454919 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# + +# Air tmp +tmp/ + # Binaries for programs and plugins *.exe *.exe~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a159440 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Marks ![Static Badge](https://img.shields.io/badge/weather-%23b16ded?style=flat&logo=github&logoColor=black&labelColor=0%2C0%2C0&link=https%3A%2F%2Fgithub.com%2FweatherGod3218%2F) + +### How Fast can ___YOU___ Eat a garbage plate?!? + +A simple timer based system for tracking how fast members of CSH can eat a garbage plate. Ohhh the things we do + +_This project uses Golang, [Gin](https://gin-gonic.com/en/), and Vite React._ + +## Documentation +All API Endpoints have swagger doucmentation, which can be viewed at `/swagger/index.html` + +# Local Development +Pre - Requirements: + - Node v20+ + - go 1.26+ + - air (`go install github.com/air-verse/air@latest`) + - PostgreSQL database + - ___OPTIONAL___: Redis cache + +## Docker Compose + +Marks uses docker compose for local development. The entire suite can be started by running +``` + docker compose up --build +``` + +Marks utilizes a local proxy for local development. Whenever air is used, it creates a proxy +for the Vite frontend development instead. This route allows all of Vite's features +along with running the application. + +## Starting NPM Dev Server +For development, enter `web/` and complete the following: + +- install web dependencies + ```bash + npm i + ``` + +- run development server + ```bash + npm run dev + ``` + +The development server will begin at port 5173. Do not use this port to access the project. + +### Starting Backend +In a new terminal, complete the following: + +- install dependencies + ```bash + go mod download + ``` + +- begin development backend server + ```bash + air + ``` + +Shoutout to Tyler Severino for the proxy + air approach for local development. diff --git a/docker-compose.yaml b/docker-compose.yaml index 196a764..e5eaa94 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,6 +8,7 @@ services: - csh-marks-network csh-marks: + build: . ports: - "8080:8080" env_file: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/INFRA.md b/docs/INFRA.md new file mode 100644 index 0000000..0206c6a --- /dev/null +++ b/docs/INFRA.md @@ -0,0 +1,40 @@ +# Database Schema + +## Events +- id (UUID) (PRIMARY) +- name (TEXT) (NOT NULL) +- created_at (TIMESTAMP) (NOT NULL) +- created_by (UUID) (NOT NULL) +- status (TEXT) (NOT NULL) (open|archived) + +## Timers +- user_uuid (UUID) (NOT NULL) UNIQUE(user_uuid,event_id) # uniquely identifies a user's timer for an event +- event_id (UUID) (NOT NULL) FOREIGN KEY(event_id) REFERENCES events(id) # references to events(id) +- start_time (TIMESTAMP) +- end_time (TIMESTAMP) + + +## Audit Logs +- id (UUID) (PRIMARY KEY) +- event_id (UUID) (NOT NULL) +- user_uuid (UUID) (NOT NULL) +- action (TEXT) (NOT NULL) +- created_at (TIMESTAMP) (NOT NULL) + +# API + +## Events +- Create __[Post]__ (Name, Who created for Audit) +- Open __[Post]__ (Id, Who's opening for Audit) +- Archive __[Post]__ (Id, Who's archiving for Audit) +- Get __[Get]__ (All Events, if not eboard or RTP just give open ones) + + +## Timer +- Start __[Post]__ (UserUUID, EventId) +- End __[Post]__ (UserUUID, EventId) + + +## Leaderboard. +- Top __[Get]__ (queryParam eventId) +- Group __[Get]__ (queryParam eventId, timerVal (for pagination)) \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 5604656..e8995d0 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1 +1,646 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/actions/group": { + "post": { + "description": "Gets a group of logged actions for auditing", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "actions" + ], + "summary": "Gets a group of logged actions", + "parameters": [ + { + "description": "Action Group", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.GetActionGroupInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.GetActionsGroupOutput" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/v1/events/archive": { + "post": { + "description": "Archives an Event to no longer be eligible for timing.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "actions" + ], + "summary": "Archives an Event", + "parameters": [ + { + "description": "Action Group", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ArchiveEventInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v1/events/create": { + "post": { + "description": "Creates a new Event instance for timer sessions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "events" + ], + "summary": "Creates a New Event Instance", + "parameters": [ + { + "description": "Name", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.CreateNewEventInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v1/events/get": { + "get": { + "description": "Gets all open Events for standard users, or everything for Authorized Admins.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "actions" + ], + "summary": "Gets All Authorized Events", + "parameters": [ + { + "description": "Action Group", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ArchiveEventInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v1/events/open": { + "post": { + "description": "Opens an Event to be eligible for timing.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "actions" + ], + "summary": "Opens an Event", + "parameters": [ + { + "description": "Action Group", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.OpenEventInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/leaderboard/delete": { + "delete": { + "description": "Deletes a User timer for a specific event, updating leaderboards accordingly", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "Removes an Entry", + "parameters": [ + { + "description": "User / Event Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RemoveLeaderboardEntryInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RemoveLeaderboardEntryOutput" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/leaderboard/top": { + "get": { + "description": "Gets Data from a leaderboard database, supports pagination with the cursor", + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "Gets Data for a Leaderboard", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.GetLeaderboardGroupOutput" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/timer/reset": { + "post": { + "description": "Resets a User's Timer for the specified event. No-op if timer is not started", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "timer" + ], + "summary": "Resets a User's Timer", + "parameters": [ + { + "description": "Event Id", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ResetUserTimerInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/timer/start": { + "post": { + "description": "Starts a User's Timer for the specified event. No-op if already started", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "timer" + ], + "summary": "Starts a User's Timer", + "parameters": [ + { + "description": "Event Id", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.StartUserTimerInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/timer/stop": { + "post": { + "description": "Stops a User's Timer for the specified event. No-op if timer is already stopped", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "timer" + ], + "summary": "Stops a User's Timer", + "parameters": [ + { + "description": "Event Id", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.StopUserTimerInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + } + }, + "definitions": { + "models.ArchiveEventInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + "models.CreateNewEventInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "models.ErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "models.GetActionGroupCursor": { + "type": "object", + "properties": { + "row_id": { + "type": "string" + }, + "timestamp": { + "type": "integer" + } + } + }, + "models.GetActionGroupInput": { + "type": "object", + "properties": { + "row_id": { + "type": "string" + }, + "timestamp": { + "type": "integer" + } + } + }, + "models.GetActionGroupPart": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "row_id": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "user_email": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "models.GetActionsGroupOutput": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/definitions/models.GetActionGroupPart" + } + }, + "cursor": { + "$ref": "#/definitions/models.GetActionGroupCursor" + } + } + }, + "models.GetLeaderboardEntry": { + "type": "object", + "properties": { + "event_name": { + "type": "string" + }, + "time": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "models.GetLeaderboardGroupOutput": { + "type": "object", + "properties": { + "can_modify": { + "type": "boolean" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/models.GetLeaderboardEntry" + } + } + } + }, + "models.GetLeaderboardTopOutput": { + "type": "object", + "properties": { + "can_modify": { + "type": "boolean" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/models.GetLeaderboardEntry" + } + } + } + }, + "models.OpenEventInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + "models.RemoveLeaderboardEntryInput": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "models.RemoveLeaderboardEntryOutput": { + "type": "object" + }, + "models.ResetUserTimerInput": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + } + } + }, + "models.StartUserTimerInput": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + } + } + }, + "models.StopUserTimerInput": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + } + } + }, + "models.UnauthorizedResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/api/v1", + Schemes: []string{}, + Title: "CSH Marks", + Description: "How Fast Can You Eat?", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..07f98e8 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,621 @@ +{ + "swagger": "2.0", + "info": { + "description": "How Fast Can You Eat?", + "title": "CSH Marks", + "contact": {}, + "version": "1.0" + }, + "basePath": "/api/v1", + "paths": { + "/api/v1/actions/group": { + "post": { + "description": "Gets a group of logged actions for auditing", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "actions" + ], + "summary": "Gets a group of logged actions", + "parameters": [ + { + "description": "Action Group", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.GetActionGroupInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.GetActionsGroupOutput" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/v1/events/archive": { + "post": { + "description": "Archives an Event to no longer be eligible for timing.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "actions" + ], + "summary": "Archives an Event", + "parameters": [ + { + "description": "Action Group", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ArchiveEventInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v1/events/create": { + "post": { + "description": "Creates a new Event instance for timer sessions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "events" + ], + "summary": "Creates a New Event Instance", + "parameters": [ + { + "description": "Name", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.CreateNewEventInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v1/events/get": { + "get": { + "description": "Gets all open Events for standard users, or everything for Authorized Admins.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "actions" + ], + "summary": "Gets All Authorized Events", + "parameters": [ + { + "description": "Action Group", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ArchiveEventInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v1/events/open": { + "post": { + "description": "Opens an Event to be eligible for timing.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "actions" + ], + "summary": "Opens an Event", + "parameters": [ + { + "description": "Action Group", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.OpenEventInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/leaderboard/delete": { + "delete": { + "description": "Deletes a User timer for a specific event, updating leaderboards accordingly", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "Removes an Entry", + "parameters": [ + { + "description": "User / Event Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RemoveLeaderboardEntryInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RemoveLeaderboardEntryOutput" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/leaderboard/top": { + "get": { + "description": "Gets Data from a leaderboard database, supports pagination with the cursor", + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "Gets Data for a Leaderboard", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.GetLeaderboardGroupOutput" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/timer/reset": { + "post": { + "description": "Resets a User's Timer for the specified event. No-op if timer is not started", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "timer" + ], + "summary": "Resets a User's Timer", + "parameters": [ + { + "description": "Event Id", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ResetUserTimerInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/timer/start": { + "post": { + "description": "Starts a User's Timer for the specified event. No-op if already started", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "timer" + ], + "summary": "Starts a User's Timer", + "parameters": [ + { + "description": "Event Id", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.StartUserTimerInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + }, + "/api/v2/timer/stop": { + "post": { + "description": "Stops a User's Timer for the specified event. No-op if timer is already stopped", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "timer" + ], + "summary": "Stops a User's Timer", + "parameters": [ + { + "description": "Event Id", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.StopUserTimerInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.UnauthorizedResponse" + } + } + } + } + } + }, + "definitions": { + "models.ArchiveEventInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + "models.CreateNewEventInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "models.ErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "models.GetActionGroupCursor": { + "type": "object", + "properties": { + "row_id": { + "type": "string" + }, + "timestamp": { + "type": "integer" + } + } + }, + "models.GetActionGroupInput": { + "type": "object", + "properties": { + "row_id": { + "type": "string" + }, + "timestamp": { + "type": "integer" + } + } + }, + "models.GetActionGroupPart": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "row_id": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "user_email": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "models.GetActionsGroupOutput": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/definitions/models.GetActionGroupPart" + } + }, + "cursor": { + "$ref": "#/definitions/models.GetActionGroupCursor" + } + } + }, + "models.GetLeaderboardEntry": { + "type": "object", + "properties": { + "event_name": { + "type": "string" + }, + "time": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "models.GetLeaderboardGroupOutput": { + "type": "object", + "properties": { + "can_modify": { + "type": "boolean" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/models.GetLeaderboardEntry" + } + } + } + }, + "models.GetLeaderboardTopOutput": { + "type": "object", + "properties": { + "can_modify": { + "type": "boolean" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/models.GetLeaderboardEntry" + } + } + } + }, + "models.OpenEventInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + "models.RemoveLeaderboardEntryInput": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "models.RemoveLeaderboardEntryOutput": { + "type": "object" + }, + "models.ResetUserTimerInput": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + } + } + }, + "models.StartUserTimerInput": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + } + } + }, + "models.StopUserTimerInput": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + } + } + }, + "models.UnauthorizedResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..1d4cd4a --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,408 @@ +basePath: /api/v1 +definitions: + models.ArchiveEventInput: + properties: + id: + type: string + type: object + models.CreateNewEventInput: + properties: + name: + type: string + type: object + models.ErrorResponse: + properties: + message: + type: string + type: object + models.GetActionGroupCursor: + properties: + row_id: + type: string + timestamp: + type: integer + type: object + models.GetActionGroupInput: + properties: + row_id: + type: string + timestamp: + type: integer + type: object + models.GetActionGroupPart: + properties: + action: + type: string + row_id: + type: string + timestamp: + type: integer + user_email: + type: string + user_id: + type: string + type: object + models.GetActionsGroupOutput: + properties: + actions: + items: + $ref: '#/definitions/models.GetActionGroupPart' + type: array + cursor: + $ref: '#/definitions/models.GetActionGroupCursor' + type: object + models.GetLeaderboardEntry: + properties: + event_name: + type: string + time: + type: integer + username: + type: string + type: object + models.GetLeaderboardGroupOutput: + properties: + can_modify: + type: boolean + entries: + items: + $ref: '#/definitions/models.GetLeaderboardEntry' + type: array + type: object + models.GetLeaderboardTopOutput: + properties: + can_modify: + type: boolean + entries: + items: + $ref: '#/definitions/models.GetLeaderboardEntry' + type: array + type: object + models.OpenEventInput: + properties: + id: + type: string + type: object + models.RemoveLeaderboardEntryInput: + properties: + event_id: + type: string + user_id: + type: string + type: object + models.RemoveLeaderboardEntryOutput: + type: object + models.ResetUserTimerInput: + properties: + event_id: + type: string + type: object + models.StartUserTimerInput: + properties: + event_id: + type: string + type: object + models.StopUserTimerInput: + properties: + event_id: + type: string + type: object + models.UnauthorizedResponse: + properties: + message: + type: string + type: object +info: + contact: {} + description: How Fast Can You Eat? + title: CSH Marks + version: "1.0" +paths: + /api/v1/actions/group: + post: + consumes: + - application/json + description: Gets a group of logged actions for auditing + parameters: + - description: Action Group + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.GetActionGroupInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.GetActionsGroupOutput' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: Gets a group of logged actions + tags: + - actions + /api/v1/events/archive: + post: + consumes: + - application/json + description: Archives an Event to no longer be eligible for timing. + parameters: + - description: Action Group + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.ArchiveEventInput' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Archives an Event + tags: + - actions + /api/v1/events/create: + post: + consumes: + - application/json + description: Creates a new Event instance for timer sessions + parameters: + - description: Name + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.CreateNewEventInput' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Creates a New Event Instance + tags: + - events + /api/v1/events/get: + get: + consumes: + - application/json + description: Gets all open Events for standard users, or everything for Authorized + Admins. + parameters: + - description: Action Group + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.ArchiveEventInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + type: string + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Gets All Authorized Events + tags: + - actions + /api/v1/events/open: + post: + consumes: + - application/json + description: Opens an Event to be eligible for timing. + parameters: + - description: Action Group + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.OpenEventInput' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Opens an Event + tags: + - actions + /api/v2/leaderboard/delete: + delete: + consumes: + - application/json + description: Deletes a User timer for a specific event, updating leaderboards + accordingly + parameters: + - description: User / Event Data + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.RemoveLeaderboardEntryInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.RemoveLeaderboardEntryOutput' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Removes an Entry + tags: + - leaderboard + /api/v2/leaderboard/top: + get: + description: Gets Data from a leaderboard database, supports pagination with + the cursor + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.GetLeaderboardGroupOutput' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Gets Data for a Leaderboard + tags: + - leaderboard + /api/v2/timer/reset: + post: + consumes: + - application/json + description: Resets a User's Timer for the specified event. No-op if timer is + not started + parameters: + - description: Event Id + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.ResetUserTimerInput' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Resets a User's Timer + tags: + - timer + /api/v2/timer/start: + post: + consumes: + - application/json + description: Starts a User's Timer for the specified event. No-op if already + started + parameters: + - description: Event Id + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.StartUserTimerInput' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Starts a User's Timer + tags: + - timer + /api/v2/timer/stop: + post: + consumes: + - application/json + description: Stops a User's Timer for the specified event. No-op if timer is + already stopped + parameters: + - description: Event Id + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.StopUserTimerInput' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.UnauthorizedResponse' + summary: Stops a User's Timer + tags: + - timer +swagger: "2.0" diff --git a/go.mod b/go.mod index 1131214..4011c8f 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/coreos/go-oidc/v3 v3.18.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/gzip v1.2.6 // indirect github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect diff --git a/go.sum b/go.sum index a6bd7d4..287dea9 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0 github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg= +github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= diff --git a/internal/api/util.go b/internal/api/util.go new file mode 100644 index 0000000..6b093eb --- /dev/null +++ b/internal/api/util.go @@ -0,0 +1,15 @@ +package api + +import "github.com/gin-gonic/gin" + +//thanks swizzle! +func CookieToAuthHeader() gin.HandlerFunc { + return func(c *gin.Context) { + cookie, err := c.Cookie("Auth") + if err == nil && cookie != "" { + c.Request.Header.Set("Authorization", "Bearer "+cookie) + } + + c.Next() + } +} diff --git a/internal/api/v1/actions/actions.go b/internal/api/v1/actions/actions.go new file mode 100644 index 0000000..cb13250 --- /dev/null +++ b/internal/api/v1/actions/actions.go @@ -0,0 +1,49 @@ +package actions + +import ( + "net/http" + + "github.com/ComputerScienceHouse/marks/internal/logging" + "github.com/ComputerScienceHouse/marks/internal/models" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +const MODULE = "v1/api/actions" //stfu sonar + +// GetActionGroup godoc +// +// @Summary Gets a group of logged actions +// @Description Gets a group of logged actions for auditing +// @Tags actions +// @Accept json +// @Produce json +// @Param request body models.GetActionGroupInput true "Action Group" +// @Success 200 {object} models.GetActionsGroupOutput +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /api/v1/actions/group [post] +func GetActionGroup(c *gin.Context) { + + var req models.GetActionGroupInput + + if err := c.ShouldBindJSON(&req); err != nil { + logging.Logger.WithFields(logrus.Fields{"error": err, "module": MODULE, "method": "GetActionGroup"}).Warning("failed to bind JSON") + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Message: "Unable to process request!", + }) + return + } + + resp := models.GetActionsGroupOutput{ + Actions: make([]models.GetActionGroupPart, 0), + } + + c.JSON(http.StatusOK, resp) +} + +func Routes(r *gin.RouterGroup) { + actions := r.Group("/actions") + + actions.POST("/group", GetActionGroup) //replace with query later +} diff --git a/internal/api/v1/events/events.go b/internal/api/v1/events/events.go index f96dfc5..530ca6b 100644 --- a/internal/api/v1/events/events.go +++ b/internal/api/v1/events/events.go @@ -1,21 +1,66 @@ package events -import "github.com/gin-gonic/gin" +import ( + "net/http" -func CreateNewEvent(c *gin.Context) { + "github.com/ComputerScienceHouse/marks/internal/models" + "github.com/gin-gonic/gin" +) +// @Summary Creates a New Event Instance +// @Description Creates a new Event instance for timer sessions +// @Tags events +// @Accept json +// @Produce json +// @Param request body models.CreateNewEventInput true "Name" +// @Success 204 "No Content" +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v1/events/create [post] +func CreateNewEvent(c *gin.Context) { + c.JSON(http.StatusNoContent, models.CreateNewEventOutput{}) } +// @Summary Opens an Event +// @Description Opens an Event to be eligible for timing. +// @Tags actions +// @Accept json +// @Produce json +// @Param request body models.OpenEventInput true "Action Group" +// @Success 204 "No Content" +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v1/events/open [post] func OpenEvent(c *gin.Context) { - + c.JSON(http.StatusNoContent, models.OpenEventOutput{}) } +// @Summary Archives an Event +// @Description Archives an Event to no longer be eligible for timing. +// @Tags actions +// @Accept json +// @Produce json +// @Param request body models.ArchiveEventInput true "Action Group" +// @Success 204 "No Content" +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v1/events/archive [post] func ArchiveEvent(c *gin.Context) { - + c.JSON(http.StatusNoContent, models.ArchiveEventOutput{}) } +// @Summary Gets All Authorized Events +// @Description Gets all open Events for standard users, or everything for Authorized Admins. +// @Tags actions +// @Accept json +// @Produce json +// @Param request body models.ArchiveEventInput true "Action Group" +// @Success 200 {object} models.GetEventsOutput +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v1/events/get [get] func GetEvents(c *gin.Context) { - + c.JSON(http.StatusOK, models.GetEventsOutput{}) } func Routes(r *gin.RouterGroup) { diff --git a/internal/api/v1/leaderboard/leaderboard.go b/internal/api/v1/leaderboard/leaderboard.go index fdd9b34..5406a34 100644 --- a/internal/api/v1/leaderboard/leaderboard.go +++ b/internal/api/v1/leaderboard/leaderboard.go @@ -1,13 +1,54 @@ package leaderboard -import "github.com/gin-gonic/gin" +import ( + "net/http" + "github.com/ComputerScienceHouse/marks/internal/models" + "github.com/gin-gonic/gin" +) + +// @Summary Gets Top of Event Leaderboard +// @Description Gets the Top 10 for an active event. +// @Tags leaderboard +// @Produce json +// @Param event path models.GetLeaderboardTopInput true "Event Id" +// @Success 200 {object} models.GetLeaderboardTopOutput +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v1/leaderboard/top [get] func GetLeaderboardTop(c *gin.Context) { //specifically needs an event, pulls from redis for speed + _ = c.Query("event") + c.JSON(http.StatusOK, models.GetLeaderboardTopOutput{}) } +// @Summary Gets Data for a Leaderboard +// @Description Gets Data from a leaderboard database, supports pagination with the cursor +// @Tags leaderboard +// @Produce json +// @Param request path models.GetLeaderboardGroupInput false "Event Id" +// @Success 200 {object} models.GetLeaderboardGroupOutput +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v1/leaderboard/top [get] func GetLeaderboardGroup(c *gin.Context) { + _ = c.Query("event") + + c.JSON(http.StatusOK, models.GetLeaderboardGroupOutput{}) +} +// @Summary Removes an Entry +// @Description Deletes a User timer for a specific event, updating leaderboards accordingly +// @Tags leaderboard +// @Accept json +// @Produce json +// @Param request body models.RemoveLeaderboardEntryInput true "User / Event Data" +// @Success 200 {object} models.RemoveLeaderboardEntryOutput +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v1/leaderboard/delete [delete] +func RemoveLeaderboardEntry(c *gin.Context) { + c.JSON(http.StatusNoContent, models.RemoveLeaderboardEntryOutput{}) } func Routes(r *gin.RouterGroup) { @@ -15,4 +56,6 @@ func Routes(r *gin.RouterGroup) { leaderboard.GET("/top", GetLeaderboardTop) leaderboard.GET("/group", GetLeaderboardGroup) + leaderboard.DELETE("/delete", RemoveLeaderboardEntry) //decide if this should be post or not + } diff --git a/internal/api/v1/routes.go b/internal/api/v1/routes.go index 425ef66..c3c0651 100644 --- a/internal/api/v1/routes.go +++ b/internal/api/v1/routes.go @@ -1,14 +1,20 @@ package v1 import ( + // "github.com/ComputerScienceHouse/marks/internal/api" "github.com/ComputerScienceHouse/marks/internal/api/v1/events" "github.com/ComputerScienceHouse/marks/internal/api/v1/leaderboard" "github.com/ComputerScienceHouse/marks/internal/api/v1/timer" + + // csh_auth "github.com/computersciencehouse/csh-auth/v2" "github.com/gin-gonic/gin" ) +// auth csh_auth.Auth for when auth is back up func SetRoutes(router *gin.RouterGroup) { v1Group := router.Group("/v1") + // v1Group.Use(api.CookieToAuthHeader()) + // v1Group.Use(auth.HeaderMiddleware()) events.Routes(v1Group) leaderboard.Routes(v1Group) diff --git a/internal/api/v1/timer/timer.go b/internal/api/v1/timer/timer.go index 9e0228c..9aac439 100644 --- a/internal/api/v1/timer/timer.go +++ b/internal/api/v1/timer/timer.go @@ -1,18 +1,58 @@ package timer -import "github.com/gin-gonic/gin" +import ( + "net/http" + "github.com/ComputerScienceHouse/marks/internal/models" + "github.com/gin-gonic/gin" +) + +// @Summary Starts a User's Timer +// @Description Starts a User's Timer for the specified event. No-op if already started +// @Tags timer +// @Accept json +// @Produce json +// @Param request body models.StartUserTimerInput true "Event Id" +// @Success 204 "No Content" +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v2/timer/start [post] func StartUserTimer(c *gin.Context) { + c.JSON(http.StatusNoContent, models.StartUserTimerOutput{}) +} +// @Summary Resets a User's Timer +// @Description Resets a User's Timer for the specified event. No-op if timer is not started +// @Tags timer +// @Accept json +// @Produce json +// @Param request body models.ResetUserTimerInput true "Event Id" +// @Success 204 "No Content" +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v2/timer/reset [post] +func ResetUserTimer(c *gin.Context) { + c.JSON(http.StatusNoContent, models.ResetUserTimerOutput{}) } +// @Summary Stops a User's Timer +// @Description Stops a User's Timer for the specified event. No-op if timer is already stopped +// @Tags timer +// @Accept json +// @Produce json +// @Param request body models.StopUserTimerInput true "Event Id" +// @Success 204 "No Content" +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.UnauthorizedResponse +// @Router /api/v2/timer/stop [post] func StopUserTimer(c *gin.Context) { - + c.JSON(http.StatusNoContent, models.StopUserTimerOutput{}) } func Routes(r *gin.RouterGroup) { - actions := r.Group("/timer") + timers := r.Group("/timer") - actions.POST("/start", StartUserTimer) - actions.POST("/end", StopUserTimer) //replace with query later + timers.POST("/start", StartUserTimer) + timers.POST("/reset", ResetUserTimer) + timers.POST("/end", StopUserTimer) } diff --git a/internal/database/database.go b/internal/database/database.go index 31feed0..b8fdbaa 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -42,6 +42,11 @@ func InitDatabase() error { } db = newDB + + err = db.Ping(ctx) + if err != nil { + return err + } DatabaseInitalized = true return nil diff --git a/internal/models/api.go b/internal/models/api.go index c183e27..a69d8d9 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -7,3 +7,7 @@ type SuccessResponse struct { type ErrorResponse struct { Message string `json:"message"` } + +type UnauthorizedResponse struct { + Message string `json:"message"` +} diff --git a/internal/models/api_actions.go b/internal/models/api_actions.go new file mode 100644 index 0000000..9fbe1c8 --- /dev/null +++ b/internal/models/api_actions.go @@ -0,0 +1,24 @@ +package models + +type GetActionGroupPart struct { + RowID string `json:"row_id"` + UserID string `json:"user_id"` + UserEmail string `json:"user_email"` + Action string `json:"action"` + Timestamp int64 `json:"timestamp"` +} + +type GetActionGroupCursor struct { + Timestamp int64 `json:"timestamp"` + RowID string `json:"row_id"` +} + +type GetActionsGroupOutput struct { + Actions []GetActionGroupPart `json:"actions"` + Cursor *GetActionGroupCursor `json:"cursor"` +} + +type GetActionGroupInput struct { + Timestamp *int64 `json:"timestamp"` + RowID *string `json:"row_id"` +} diff --git a/internal/models/api_events.go b/internal/models/api_events.go new file mode 100644 index 0000000..7e8b530 --- /dev/null +++ b/internal/models/api_events.go @@ -0,0 +1,18 @@ +package models + +type CreateNewEventInput struct { + Name string `json:"name"` +} +type CreateNewEventOutput struct{} + +type OpenEventInput struct { + ID string `json:"id"` +} +type OpenEventOutput struct{} + +type ArchiveEventInput struct { + ID string `json:"id"` +} +type ArchiveEventOutput struct{} + +type GetEventsOutput []string diff --git a/internal/models/api_leaderboard.go b/internal/models/api_leaderboard.go new file mode 100644 index 0000000..4419b84 --- /dev/null +++ b/internal/models/api_leaderboard.go @@ -0,0 +1,28 @@ +package models + +type GetLeaderboardTopInput string + +type GetLeaderboardTopOutput struct { + Entries []GetLeaderboardEntry `json:"entries"` + CanModify bool `json:"can_modify"` +} + +type GetLeaderboardGroupInput string + +type GetLeaderboardGroupOutput struct { + Entries []GetLeaderboardEntry `json:"entries"` + CanModify bool `json:"can_modify"` +} + +type RemoveLeaderboardEntryInput struct { + EventID string `json:"event_id"` + UserID string `json:"user_id"` +} + +type RemoveLeaderboardEntryOutput struct{} + +type GetLeaderboardEntry struct { + Username string `json:"username"` + EventName string `json:"event_name"` + Time int64 `json:"time"` +} diff --git a/internal/models/api_timer.go b/internal/models/api_timer.go new file mode 100644 index 0000000..e362af3 --- /dev/null +++ b/internal/models/api_timer.go @@ -0,0 +1,16 @@ +package models + +type StartUserTimerInput struct { + EventID string `json:"event_id"` +} +type StartUserTimerOutput struct{} + +type ResetUserTimerInput struct { + EventID string `json:"event_id"` +} +type ResetUserTimerOutput struct{} + +type StopUserTimerInput struct { + EventID string `json:"event_id"` +} +type StopUserTimerOutput struct{} diff --git a/internal/models/leaderboard.go b/internal/models/leaderboard.go new file mode 100644 index 0000000..3b771b0 --- /dev/null +++ b/internal/models/leaderboard.go @@ -0,0 +1,9 @@ +package models + +type LeaderboardEntry struct { + UserUUID string `json:"user_uuid"` + Username string `json:"username"` + + EventID string `json:"event_id"` + EventName string `json:"event_name"` +} diff --git a/internal/redis/redis.go b/internal/redis/redis.go index 9f5d952..ba88fd9 100644 --- a/internal/redis/redis.go +++ b/internal/redis/redis.go @@ -29,5 +29,11 @@ func InitRedis() error { RedisInitalized = true client = newClient + + _, err = client.Ping(ctx).Result() + if err != nil { + _ = client.Close() + return err + } return nil } diff --git a/internal/users/users.go b/internal/users/users.go new file mode 100644 index 0000000..acac9ea --- /dev/null +++ b/internal/users/users.go @@ -0,0 +1,63 @@ +package users + +import ( + "errors" + "os" + "slices" + "strconv" + + "github.com/ComputerScienceHouse/marks/internal/logging" + csh_auth "github.com/computersciencehouse/csh-auth/v2" + "github.com/gin-gonic/gin" +) + +func IsEboard(user *csh_auth.Claims) bool { + val, set := os.LookupEnv("DEV_FORCE_IS_EBOARD") + if set { + forced, err := strconv.ParseBool(val) + if err == nil { + logging.Logger.Info("Forced Override for Eboard") + return forced + } + logging.Logger.Warn("FORCED EBOARD WAS MISTYPED, MAKE SURE IT'S EITHER true OR false") + } + + return slices.Contains(user.Groups, "eboard") +} + +func IsActiveRTP(user *csh_auth.Claims) bool { + val, set := os.LookupEnv("DEV_FORCE_IS_RTP") + if set { + forced, err := strconv.ParseBool(val) + if err == nil { + logging.Logger.Info("Forced Override for RTP") + return forced + } + logging.Logger.Warn("FORCED RTP WAS MISTYPED, MAKE SURE IT'S EITHER true OR false") + } + + return slices.Contains(user.Groups, "active-rtp") +} + +func GetCSHAuth(c *gin.Context) (*csh_auth.UserInfo, error) { + userAny, exists := c.Get("cshauth") + + if !exists { + return nil, errors.New("unable to load csh auth") + } + + userClaims, ok := userAny.(*csh_auth.Claims) + if !ok { + return nil, errors.New("unable to cast csh auth") + } + + user := &csh_auth.UserInfo{ + Uuid: userClaims.Uuid, + Email: userClaims.Email, + Username: userClaims.Username, + FullName: userClaims.FullName, + Groups: userClaims.Groups, + } + + return user, nil +} diff --git a/main.go b/main.go index 6fea92b..b10ab92 100644 --- a/main.go +++ b/main.go @@ -14,8 +14,8 @@ import ( "github.com/ComputerScienceHouse/marks/internal/models" "github.com/ComputerScienceHouse/marks/internal/redis" - cshauth "github.com/computersciencehouse/csh-auth/v2" "github.com/gin-contrib/cors" + "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" _ "github.com/ComputerScienceHouse/marks/docs" @@ -37,11 +37,25 @@ func serveFrontend(c *gin.Context) { } func chooseRouter() *gin.Engine { + var router *gin.Engine if os.Getenv("DEV_MODE") == "true" { - return gin.Default() + router = gin.Default() + + router.Use(gin.LoggerWithConfig(gin.LoggerConfig{ //shut the f up + SkipPaths: []string{"/health"}, + })) + + } else { + router = gin.New() } - return gin.New() + router.Use(gzip.Gzip(gzip.DefaultCompression)) + router.Use(cors.Default()) + + router.GET("/health", func(ctx *gin.Context) { + ctx.JSON(http.StatusNoContent, gin.H{}) + }) + return router } func createFrontend(frontend *gin.RouterGroup) error { @@ -63,10 +77,12 @@ func createFrontend(frontend *gin.RouterGroup) error { icon, err := fs.ReadFile(dist, "favicon.png") if err != nil { - return fmt.Errorf("failed to load favicon %s", err) + icon, err = fs.ReadFile(dist, "favicon.svg") //fallback + if err != nil { + return fmt.Errorf("failed to load favicon %s", err) + } } - frontend.Use(cors.Default()) frontend.StaticFS("/assets", http.FS(assets)) frontend.GET("/favicon.ico", func(ctx *gin.Context) { @@ -104,30 +120,23 @@ func main() { if err := database.InitDatabase(); err != nil { //yeah we need ts gng :pray: - // logging.Logger.Fatalf("failed to initialize database for the app %s", err) ignore for now + logging.Logger.Warnf("failed to initialize database for the app %s", err) } - hostUrl := os.Getenv("SERVER_HOST") - auth, err := cshauth.Init( - os.Getenv("AUTH_OIDC_ID"), - os.Getenv("AUTH_OIDC_SECRET"), - hostUrl, - hostUrl+"/auth/login", - hostUrl+"/auth/callback", - []string{"profile", "email", "groups"}, - ) - if err != nil { - logging.Logger.Fatalf("failed to initialize csh auth for the app %s", err) - } + // hostUrl := os.Getenv("SERVER_HOST") + // auth, err := csh_auth.Init( + // os.Getenv("AUTH_OIDC_ID"), + // os.Getenv("AUTH_OIDC_SECRET"), + // hostUrl, + // hostUrl+"/auth/login", + // hostUrl+"/auth/callback", + // []string{"profile", "email", "groups"}, + // ) + // if err != nil { + // logging.Logger.Fatalf("failed to initialize csh auth for the app %s", err) + // } router := chooseRouter() - router.GET("/health", func(ctx *gin.Context) { - ctx.JSON(http.StatusNoContent, gin.H{}) - }) - - router.Use(gin.LoggerWithConfig(gin.LoggerConfig{ //shut the f up - SkipPaths: []string{"/health"}, - })) frontend := router.Group("") api := router.Group("/api") @@ -141,7 +150,8 @@ func main() { logging.Logger.Fatalf("failed to create frontend %s", err) } - router.NoRoute(auth.CookieMiddleware(), func(c *gin.Context) { + router.NoRoute(func(c *gin.Context) { + // router.NoRoute(auth.CookieMiddleware(), func(c *gin.Context) { reqURL := c.Request.URL.String() if strings.Contains(reqURL, "api/") { @@ -155,5 +165,8 @@ func main() { } - router.Run(":8080") + err := router.Run(":8080") + if err != nil { + logging.Logger.Fatalf("router shutting down %s", err) + } } diff --git a/tmp/build-errors.log b/tmp/build-errors.log deleted file mode 100644 index ccd0cd6..0000000 --- a/tmp/build-errors.log +++ /dev/null @@ -1 +0,0 @@ -exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file