diff --git a/Makefile b/Makefile index f1ea654..896d42b 100644 --- a/Makefile +++ b/Makefile @@ -30,4 +30,7 @@ run: build: @set GOOS=linux& set GOARCH=amd64& set CGO_ENABLED=0&go build -trimpath -ldflags="-s -w" -o build/history-api $(MAIN_APP) -dev: swagger sqlc migrate-up run \ No newline at end of file +dev: swagger sqlc migrate-up run + +docker-up: + docker compose --env-file ./assets/resources/.env up -d --build --remove-orphans diff --git a/cmd/api/server.go b/cmd/api/server.go index 5ecb141..20150f0 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -5,7 +5,6 @@ import ( _ "embed" "history-api/docs" "history-api/internal/controllers" - "history-api/internal/gen/sqlc" "history-api/internal/repositories" "history-api/internal/routes" "history-api/internal/services" @@ -18,6 +17,7 @@ import ( middleware "github.com/gofiber/contrib/v3/zerolog" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/cors" + "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog" "golang.org/x/oauth2" ) @@ -57,7 +57,7 @@ func NewHttpServer() *FiberServer { } func (s *FiberServer) SetupServer( - sqlPg sqlc.DBTX, + poolPg *pgxpool.Pool, sqlTile *sql.DB, sqlRasterTile *sql.DB, redis cache.Cache, @@ -77,30 +77,34 @@ func (s *FiberServer) SetupServer( })) // repo setup - userRepo := repositories.NewUserRepository(sqlPg, redis) - roleRepo := repositories.NewRoleRepository(sqlPg, redis) + userRepo := repositories.NewUserRepository(poolPg, redis) + roleRepo := repositories.NewRoleRepository(poolPg, redis) tileRepo := repositories.NewTileRepository(sqlTile, redis) rasterTileRepo := repositories.NewRasterTileRepository(sqlRasterTile, redis) tokenRepo := repositories.NewTokenRepository(redis) - mediaRepo := repositories.NewMediaRepository(sqlPg, redis) - verificationRepo := repositories.NewVerificationRepository(sqlPg, redis) - entityRepo := repositories.NewEntityRepository(sqlPg, redis) - geometryRepo := repositories.NewGeometryRepository(sqlPg, redis) - wikiRepo := repositories.NewWikiRepository(sqlPg, redis) - projectRepo := repositories.NewProjectRepository(sqlPg, redis) + mediaRepo := repositories.NewMediaRepository(poolPg, redis) + verificationRepo := repositories.NewVerificationRepository(poolPg, redis) + entityRepo := repositories.NewEntityRepository(poolPg, redis) + geometryRepo := repositories.NewGeometryRepository(poolPg, redis) + wikiRepo := repositories.NewWikiRepository(poolPg, redis) + projectRepo := repositories.NewProjectRepository(poolPg, redis) + commitRepo := repositories.NewCommitRepository(poolPg, redis) + submissionRepo := repositories.NewSubmissionRepository(poolPg, redis) // service setup - authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis) - userService := services.NewUserService(userRepo, roleRepo, redis) + authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis, poolPg) + userService := services.NewUserService(userRepo, roleRepo, redis, poolPg) roleService := services.NewRoleService(roleRepo) tileService := services.NewTileService(tileRepo) rasterTileService := services.NewRasterTileService(rasterTileRepo) mediaService := services.NewMediaService(mediaRepo, tokenRepo, sclient, redis) - verificationService := services.NewVerificationService(verificationRepo, mediaRepo, userRepo, roleRepo, redis) + verificationService := services.NewVerificationService(verificationRepo, mediaRepo, userRepo, roleRepo, redis, poolPg) entityService := services.NewEntityService(entityRepo) geometryService := services.NewGeometryService(geometryRepo) wikiService := services.NewWikiService(wikiRepo) projectService := services.NewProjectService(projectRepo) + commitService := services.NewCommitService(poolPg, commitRepo, projectRepo) + submissionService := services.NewSubmissionService(submissionRepo, projectRepo, commitRepo, userRepo, poolPg, redis) // controller setup authController := controllers.NewAuthController(authService, oauth) @@ -114,6 +118,8 @@ func (s *FiberServer) SetupServer( geometryController := controllers.NewGeometryController(geometryService) wikiController := controllers.NewWikiController(wikiService) projectController := controllers.NewProjectController(projectService) + commitController := controllers.NewCommitController(commitService) + submissionController := controllers.NewSubmissionController(submissionService) // route setup routes.AuthRoutes(s.App, authController, userRepo) @@ -126,6 +132,7 @@ func (s *FiberServer) SetupServer( routes.EntityRoutes(s.App, entityController) routes.GeometryRoutes(s.App, geometryController) routes.WikiRoutes(s.App, wikiController) - routes.ProjectRoutes(s.App, projectController, userRepo) + routes.ProjectRoutes(s.App, projectController, commitController, userRepo) + routes.SubmissionRoutes(s.App, submissionController, userRepo) routes.NotFoundRoute(s.App) } diff --git a/db/migrations/0000010_commit.down.sql b/db/migrations/0000010_commit.down.sql new file mode 100644 index 0000000..e13fac3 --- /dev/null +++ b/db/migrations/0000010_commit.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS commits; diff --git a/db/migrations/0000010_revision.up.sql b/db/migrations/0000010_commit.up.sql similarity index 50% rename from db/migrations/0000010_revision.up.sql rename to db/migrations/0000010_commit.up.sql index 2492d3e..b103961 100644 --- a/db/migrations/0000010_revision.up.sql +++ b/db/migrations/0000010_commit.up.sql @@ -1,21 +1,16 @@ -CREATE TABLE IF NOT EXISTS revisions ( +CREATE TABLE IF NOT EXISTS commits ( id UUID PRIMARY KEY DEFAULT uuidv7(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - version_no INT NOT NULL, snapshot_json JSONB NOT NULL, snapshot_hash TEXT, - parent_id UUID REFERENCES revisions(id), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, edit_summary TEXT, is_deleted BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_revisions_project_history -ON revisions (project_id, version_no DESC); +CREATE INDEX idx_commits_project_history +ON commits (project_id, created_at DESC); -CREATE INDEX idx_revisions_user_history -ON revisions (user_id, created_at DESC); - -CREATE INDEX idx_revisions_parent_id -ON revisions (parent_id); \ No newline at end of file +CREATE INDEX idx_commits_user_history +ON commits (user_id, created_at DESC); \ No newline at end of file diff --git a/db/migrations/0000010_revision.down.sql b/db/migrations/0000010_revision.down.sql deleted file mode 100644 index 61d7b53..0000000 --- a/db/migrations/0000010_revision.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS revisions; diff --git a/db/migrations/0000011_submission.up.sql b/db/migrations/0000011_submission.up.sql index 99ee4f7..3a6c5aa 100644 --- a/db/migrations/0000011_submission.up.sql +++ b/db/migrations/0000011_submission.up.sql @@ -1,24 +1,25 @@ CREATE TABLE IF NOT EXISTS submissions ( id UUID PRIMARY KEY DEFAULT uuidv7(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - revision_id UUID NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, - submitted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + commit_id UUID NOT NULL REFERENCES commits(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, status SMALLINT NOT NULL DEFAULT 1, reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, reviewed_at TIMESTAMPTZ, review_note TEXT, - is_deleted BOOLEAN NOT NULL DEFAULT false + content TEXT, + is_deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_submissions_revision_id -ON submissions (revision_id); +CREATE INDEX idx_submissions_commit_id +ON submissions (commit_id); CREATE INDEX idx_submissions_moderator_queue -ON submissions (status, submitted_at ASC); +ON submissions (status, created_at ASC); CREATE INDEX idx_submissions_user_history -ON submissions (submitted_by, status, submitted_at DESC); +ON submissions (user_id, status, created_at DESC); CREATE INDEX idx_submissions_project_queue -ON submissions (project_id, submitted_at DESC); \ No newline at end of file +ON submissions (project_id, created_at DESC); \ No newline at end of file diff --git a/db/migrations/0000012_project_members.down.sql b/db/migrations/0000012_project_members.down.sql new file mode 100644 index 0000000..6fb3c4f --- /dev/null +++ b/db/migrations/0000012_project_members.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_members; diff --git a/db/migrations/0000012_project_members.up.sql b/db/migrations/0000012_project_members.up.sql new file mode 100644 index 0000000..7fdea1f --- /dev/null +++ b/db/migrations/0000012_project_members.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS project_members ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role SMALLINT NOT NULL DEFAULT 3, -- 1=owner, 2=editor, 3=viewer + invited_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, user_id) +); + +CREATE INDEX idx_project_members_user +ON project_members (user_id, role); + +CREATE INDEX idx_project_members_project +ON project_members (project_id, role); diff --git a/db/migrations/000009_project.up.sql b/db/migrations/000009_project.up.sql index 7f7ace3..6cc0f1b 100644 --- a/db/migrations/000009_project.up.sql +++ b/db/migrations/000009_project.up.sql @@ -2,8 +2,7 @@ CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT uuidv7(), title TEXT NOT NULL, description TEXT, - latest_revision_id UUID, - version_count INT NOT NULL DEFAULT 0, + latest_commit_id UUID, project_status SMALLINT NOT NULL DEFAULT 1, locked_by UUID, is_deleted BOOLEAN NOT NULL DEFAULT false, @@ -13,8 +12,8 @@ CREATE TABLE IF NOT EXISTS projects ( ); -CREATE INDEX idx_projects_latest_revision_id -ON projects (latest_revision_id); +CREATE INDEX idx_projects_latest_commit_id +ON projects (latest_commit_id); CREATE INDEX idx_projects_user_status_updated ON projects (user_id, project_status, updated_at DESC); diff --git a/db/query/commit.sql b/db/query/commit.sql new file mode 100644 index 0000000..befac10 --- /dev/null +++ b/db/query/commit.sql @@ -0,0 +1,36 @@ +-- name: CreateCommit :one +INSERT INTO commits ( + project_id, snapshot_json, snapshot_hash, user_id, edit_summary +) VALUES ( + $1, $2, $3, $4, $5 +) +RETURNING *; + +-- name: GetCommitById :one +SELECT * +FROM commits +WHERE id = $1 AND is_deleted = false; + +-- name: GetCommitsByProjectID :many +SELECT * +FROM commits +WHERE project_id = $1 AND is_deleted = false +ORDER BY created_at DESC; + +-- name: DeleteCommit :exec +UPDATE commits +SET is_deleted = true +WHERE id = $1; + +-- name: SearchCommits :many +SELECT * +FROM commits +WHERE is_deleted = false + AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) + AND (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id')) + AND (sqlc.narg('cursor_id')::uuid IS NULL OR id < sqlc.narg('cursor_id')::uuid) +ORDER BY created_at DESC +LIMIT sqlc.arg('limit'); + +-- name: GetCommitsByIDs :many +SELECT * FROM commits WHERE id = ANY($1::uuid[]) AND is_deleted = false; diff --git a/db/query/project.sql b/db/query/project.sql index 254e668..a233546 100644 --- a/db/query/project.sql +++ b/db/query/project.sql @@ -13,16 +13,18 @@ RETURNING 'full_name', up.full_name, 'avatar_url', up.avatar_url )::json AS user, - '{}'::uuid[] AS commit_ids, - '{}'::uuid[] AS submission_ids; + '[]'::json AS commits, + '{}'::uuid[] AS submission_ids, + '[]'::json AS members; -- name: GetProjectById :one SELECT p.*, COALESCE( - (SELECT array_agg(id) FROM revisions WHERE project_id = p.id), - '{}' - )::uuid[] AS commit_ids, + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), + '[]' + )::json AS commits, COALESCE( (SELECT array_agg(id) FROM submissions WHERE project_id = p.id), '{}' @@ -33,7 +35,18 @@ SELECT 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS user + )::json AS user, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = p.id), + '[]' + )::json AS members FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id @@ -44,8 +57,7 @@ UPDATE projects SET title = COALESCE(sqlc.narg('title'), title), description = COALESCE(sqlc.narg('description'), description), - latest_revision_id = COALESCE(sqlc.narg('latest_revision_id'), latest_revision_id), - version_count = COALESCE(sqlc.narg('version_count'), version_count), + latest_commit_id = COALESCE(sqlc.narg('latest_commit_id'), latest_commit_id), project_status = COALESCE(sqlc.narg('status'), status), locked_by = COALESCE(sqlc.narg('locked_by'), locked_by), updated_at = NOW() @@ -62,8 +74,23 @@ RETURNING 'full_name', up.full_name, 'avatar_url', up.avatar_url )::json AS user, - COALESCE((SELECT array_agg(id) FROM revisions WHERE project_id = projects.id), '{}')::uuid[] AS commit_ids, - COALESCE((SELECT array_agg(id) FROM submissions WHERE project_id = projects.id), '{}')::uuid[] AS submission_ids; + COALESCE( + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = projects.id AND c.is_deleted = false), + '[]' + )::json AS commits, + COALESCE((SELECT array_agg(id) FROM submissions WHERE project_id = projects.id), '{}')::uuid[] AS submission_ids, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = projects.id), + '[]' + )::json AS members; -- name: DeleteProject :exec UPDATE projects @@ -75,9 +102,10 @@ WHERE id = $1; SELECT p.*, COALESCE( - (SELECT array_agg(id) FROM revisions WHERE project_id = p.id), - '{}' - )::uuid[] AS commit_ids, + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), + '[]' + )::json AS commits, COALESCE( (SELECT array_agg(id) FROM submissions WHERE project_id = p.id), '{}' @@ -88,14 +116,25 @@ SELECT 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS user + )::json AS user, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = p.id), + '[]' + )::json AS members FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id WHERE p.is_deleted = false AND ( - sqlc.narg('statuses')::text[] IS NULL - OR p.project_status = ANY(sqlc.narg('statuses')::text[]) + sqlc.narg('statuses')::smallint[] IS NULL + OR p.project_status = ANY(sqlc.narg('statuses')::smallint[]) ) AND (sqlc.narg('user_ids')::uuid[] IS NULL OR p.user_id = ANY(sqlc.narg('user_ids')::uuid[])) AND ( @@ -121,8 +160,8 @@ SELECT count(*) FROM projects p WHERE p.is_deleted = false AND ( - sqlc.narg('statuses')::text[] IS NULL - OR p.project_status = ANY(sqlc.narg('statuses')::text[]) + sqlc.narg('statuses')::smallint[] IS NULL + OR p.project_status = ANY(sqlc.narg('statuses')::smallint[]) ) AND (sqlc.narg('user_ids')::uuid[] IS NULL OR p.user_id = ANY(sqlc.narg('user_ids')::uuid[])) AND ( @@ -137,9 +176,10 @@ WHERE p.is_deleted = false SELECT p.*, COALESCE( - (SELECT array_agg(id) FROM revisions WHERE project_id = p.id), - '{}' - )::uuid[] AS commit_ids, + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), + '[]' + )::json AS commits, COALESCE( (SELECT array_agg(id) FROM submissions WHERE project_id = p.id), '{}' @@ -150,21 +190,35 @@ SELECT 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS user + )::json AS user, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = p.id), + '[]' + )::json AS members FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id -WHERE p.user_id = $1 +WHERE (p.user_id = $1 OR EXISTS (SELECT 1 FROM project_members pm2 WHERE pm2.project_id = p.id AND pm2.user_id = $1)) AND p.is_deleted = false AND (sqlc.narg('cursor_id')::uuid IS NULL OR p.id < sqlc.narg('cursor_id')::uuid) ORDER BY p.updated_at DESC LIMIT sqlc.arg('limit'); - -- name: GetProjectsByIDs :many SELECT p.*, - COALESCE((SELECT array_agg(id) FROM revisions WHERE project_id = p.id), '{}')::uuid[] AS commit_ids, + COALESCE( + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), + '[]' + )::json AS commits, COALESCE((SELECT array_agg(id) FROM submissions WHERE project_id = p.id), '{}')::uuid[] AS submission_ids, json_build_object( 'id', u.id, @@ -172,8 +226,51 @@ SELECT 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS user + )::json AS user, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = p.id), + '[]' + )::json AS members FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id WHERE p.id = ANY($1::uuid[]) AND p.is_deleted = false; + +-- name: AddProjectMember :one +INSERT INTO project_members ( + project_id, user_id, role, invited_by +) VALUES ( + $1, $2, $3, $4 +) +RETURNING *; + +-- name: UpdateProjectMemberRole :one +UPDATE project_members +SET role = $3 +WHERE project_id = $1 AND user_id = $2 +RETURNING *; + +-- name: RemoveProjectMember :exec +DELETE FROM project_members +WHERE project_id = $1 AND user_id = $2; + +-- name: CheckProjectPermission :one +SELECT role FROM project_members +WHERE project_id = $1 AND user_id = $2; + +-- name: ChangeProjectOwner :exec +UPDATE projects +SET user_id = $2, updated_at = NOW() +WHERE id = $1 AND is_deleted = false; + +-- name: UpdateLatestCommit :exec +UPDATE projects +SET latest_commit_id = $2, updated_at = NOW() +WHERE id = $1 AND is_deleted = false; diff --git a/db/query/revision.sql b/db/query/revision.sql deleted file mode 100644 index 22d70f9..0000000 --- a/db/query/revision.sql +++ /dev/null @@ -1,30 +0,0 @@ --- name: CreateRevision :one -INSERT INTO revisions ( - project_id, version_no, snapshot_json, snapshot_hash, parent_id, user_id, edit_summary -) VALUES ( - $1, $2, $3, $4, $5, $6, $7 -) -RETURNING *; - --- name: GetRevisionById :one -SELECT * -FROM revisions -WHERE id = $1 AND is_deleted = false; - --- name: DeleteRevision :exec -UPDATE revisions -SET is_deleted = true -WHERE id = $1; - --- name: SearchRevisions :many -SELECT * -FROM revisions -WHERE is_deleted = false - AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) - AND (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id')) - AND (sqlc.narg('cursor_id')::uuid IS NULL OR id < sqlc.narg('cursor_id')::uuid) -ORDER BY version_no DESC -LIMIT sqlc.arg('limit'); - --- name: GetRevisionsByIDs :many -SELECT * FROM revisions WHERE id = ANY($1::uuid[]) AND is_deleted = false; diff --git a/db/query/submission.sql b/db/query/submission.sql index 4935a6c..ad633e0 100644 --- a/db/query/submission.sql +++ b/db/query/submission.sql @@ -1,46 +1,46 @@ -- name: CreateSubmission :one -WITH inserted_submission AS ( - INSERT INTO submissions ( - project_id, revision_id, submitted_by, status - ) VALUES ( - $1, $2, $3, $4 - ) - RETURNING * +INSERT INTO submissions ( + project_id, commit_id, user_id, status, content +) VALUES ( + $1, $2, $3, $4, $5 ) -SELECT - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, - json_build_object( - 'id', u.id, - 'email', u.email, - 'display_name', up.display_name, - 'full_name', up.full_name, - 'avatar_url', up.avatar_url - )::json AS submitter, - CASE WHEN s.reviewed_by IS NOT NULL THEN - json_build_object( +RETURNING + id, project_id, commit_id, user_id, created_at, status, reviewed_by, reviewed_at, review_note, content, is_deleted, + ( + SELECT json_build_object( + 'id', u.id, + 'email', u.email, + 'display_name', up.display_name, + 'full_name', up.full_name, + 'avatar_url', up.avatar_url + ) + FROM users u + LEFT JOIN user_profiles up ON u.id = up.user_id + WHERE u.id = submissions.user_id + )::json AS user, + ( + SELECT json_build_object( 'id', ru.id, 'email', ru.email, 'display_name', rup.display_name, 'full_name', rup.full_name, 'avatar_url', rup.avatar_url - )::json - ELSE NULL::json END AS reviewer -FROM inserted_submission s -JOIN users u ON s.submitted_by = u.id -LEFT JOIN user_profiles up ON u.id = up.user_id -LEFT JOIN users ru ON s.reviewed_by = ru.id -LEFT JOIN user_profiles rup ON ru.id = rup.user_id; + ) + FROM users ru + LEFT JOIN user_profiles rup ON ru.id = rup.user_id + WHERE ru.id = submissions.reviewed_by + )::json AS reviewer; -- name: GetSubmissionById :one SELECT - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, + s.id, s.project_id, s.commit_id, s.user_id, s.created_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.content, s.is_deleted, json_build_object( 'id', u.id, 'email', u.email, 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS submitter, + )::json AS user, CASE WHEN s.reviewed_by IS NOT NULL THEN json_build_object( 'id', ru.id, @@ -51,7 +51,7 @@ SELECT )::json ELSE NULL::json END AS reviewer FROM submissions s -JOIN users u ON s.submitted_by = u.id +JOIN users u ON s.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id LEFT JOIN users ru ON s.reviewed_by = ru.id LEFT JOIN user_profiles rup ON ru.id = rup.user_id @@ -62,23 +62,23 @@ UPDATE submissions SET status = COALESCE(sqlc.narg('status'), status), reviewed_by = COALESCE(sqlc.narg('reviewed_by'), reviewed_by), - reviewed_at = COALESCE(sqlc.narg('reviewed_at'), reviewed_at), - review_note = COALESCE(sqlc.narg('review_note'), review_note) + review_note = COALESCE(sqlc.narg('review_note'), review_note), + content = COALESCE(sqlc.narg('content'), content) FROM submissions s -JOIN users u ON s.submitted_by = u.id +JOIN users u ON s.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id LEFT JOIN users ru ON s.reviewed_by = ru.id LEFT JOIN user_profiles rup ON ru.id = rup.user_id WHERE s.id = sqlc.arg('id') RETURNING - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, + s.id, s.project_id, s.commit_id, s.user_id, s.created_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.content, s.is_deleted, json_build_object( 'id', u.id, 'email', u.email, 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS submitter, + )::json AS user, CASE WHEN s.reviewed_by IS NOT NULL THEN json_build_object( 'id', ru.id, @@ -96,14 +96,14 @@ WHERE id = $1; -- name: SearchSubmissions :many SELECT - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, + s.id, s.project_id, s.commit_id, s.user_id, s.created_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.content, s.is_deleted, json_build_object( 'id', u.id, 'email', u.email, 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS submitter, + )::json AS user, CASE WHEN s.reviewed_by IS NOT NULL THEN json_build_object( 'id', ru.id, @@ -114,33 +114,34 @@ SELECT )::json ELSE NULL::json END AS reviewer FROM submissions s -JOIN users u ON s.submitted_by = u.id +JOIN users u ON s.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id LEFT JOIN users ru ON s.reviewed_by = ru.id LEFT JOIN user_profiles rup ON ru.id = rup.user_id WHERE s.is_deleted = false AND (sqlc.narg('project_id')::uuid IS NULL OR s.project_id = sqlc.narg('project_id')) - AND (sqlc.narg('submitted_by')::uuid IS NULL OR s.submitted_by = sqlc.narg('submitted_by')) + AND (sqlc.narg('user_ids')::uuid[] IS NULL OR uv.user_id = ANY(sqlc.narg('user_ids')::uuid[])) AND (sqlc.narg('reviewed_by')::uuid IS NULL OR s.reviewed_by = sqlc.narg('reviewed_by')) AND ( - sqlc.narg('statuses')::text[] IS NULL - OR s.status = ANY(sqlc.narg('statuses')::text[]) + sqlc.narg('statuses')::smallint[] IS NULL + OR s.status = ANY(sqlc.narg('statuses')::smallint[]) ) - AND (sqlc.narg('created_from')::timestamptz IS NULL OR s.submitted_at >= sqlc.narg('created_from')::timestamptz) - AND (sqlc.narg('created_to')::timestamptz IS NULL OR s.submitted_at <= sqlc.narg('created_to')::timestamptz) + AND (sqlc.narg('created_from')::timestamptz IS NULL OR s.created_at >= sqlc.narg('created_from')::timestamptz) + AND (sqlc.narg('created_to')::timestamptz IS NULL OR s.created_at <= sqlc.narg('created_to')::timestamptz) AND ( sqlc.narg('search_text')::text IS NULL OR s.id::text ILIKE '%' || sqlc.narg('search_text')::text || '%' OR - s.review_note ILIKE '%' || sqlc.narg('search_text')::text || '%' + s.review_note ILIKE '%' || sqlc.narg('search_text')::text || '%' OR + s.content ILIKE '%' || sqlc.narg('search_text')::text || '%' ) ORDER BY - CASE WHEN sqlc.narg('sort') = 'submitted_at' AND sqlc.narg('order') = 'asc' THEN s.submitted_at END ASC, - CASE WHEN sqlc.narg('sort') = 'submitted_at' AND sqlc.narg('order') = 'desc' THEN s.submitted_at END DESC, + CASE WHEN sqlc.narg('sort') = 'created_at' AND sqlc.narg('order') = 'asc' THEN s.created_at END ASC, + CASE WHEN sqlc.narg('sort') = 'created_at' AND sqlc.narg('order') = 'desc' THEN s.created_at END DESC, CASE WHEN sqlc.narg('sort') = 'reviewed_at' AND sqlc.narg('order') = 'asc' THEN s.reviewed_at END ASC, CASE WHEN sqlc.narg('sort') = 'reviewed_at' AND sqlc.narg('order') = 'desc' THEN s.reviewed_at END DESC, CASE WHEN sqlc.narg('sort') = 'status' AND sqlc.narg('order') = 'asc' THEN s.status END ASC, CASE WHEN sqlc.narg('sort') = 'status' AND sqlc.narg('order') = 'desc' THEN s.status END DESC, - CASE WHEN sqlc.narg('sort') IS NULL THEN s.submitted_at END DESC + CASE WHEN sqlc.narg('sort') IS NULL THEN s.created_at END DESC LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); @@ -149,31 +150,31 @@ SELECT count(*) FROM submissions s WHERE s.is_deleted = false AND (sqlc.narg('project_id')::uuid IS NULL OR s.project_id = sqlc.narg('project_id')) - AND (sqlc.narg('submitted_by')::uuid IS NULL OR s.submitted_by = sqlc.narg('submitted_by')) + AND (sqlc.narg('user_ids')::uuid[] IS NULL OR uv.user_id = ANY(sqlc.narg('user_ids')::uuid[])) AND (sqlc.narg('reviewed_by')::uuid IS NULL OR s.reviewed_by = sqlc.narg('reviewed_by')) AND ( - sqlc.narg('statuses')::text[] IS NULL - OR s.status = ANY(sqlc.narg('statuses')::text[]) + sqlc.narg('statuses')::smallint[] IS NULL + OR s.status = ANY(sqlc.narg('statuses')::smallint[]) ) - AND (sqlc.narg('created_from')::timestamptz IS NULL OR s.submitted_at >= sqlc.narg('created_from')::timestamptz) - AND (sqlc.narg('created_to')::timestamptz IS NULL OR s.submitted_at <= sqlc.narg('created_to')::timestamptz) + AND (sqlc.narg('created_from')::timestamptz IS NULL OR s.created_at >= sqlc.narg('created_from')::timestamptz) + AND (sqlc.narg('created_to')::timestamptz IS NULL OR s.created_at <= sqlc.narg('created_to')::timestamptz) AND ( sqlc.narg('search_text')::text IS NULL OR s.id::text ILIKE '%' || sqlc.narg('search_text')::text || '%' OR - s.review_note ILIKE '%' || sqlc.narg('search_text')::text || '%' + s.review_note ILIKE '%' || sqlc.narg('search_text')::text || '%' OR + s.content ILIKE '%' || sqlc.narg('search_text')::text || '%' ); - -- name: GetSubmissionsByIDs :many SELECT - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, + s.id, s.project_id, s.commit_id, s.user_id, s.created_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.content, s.is_deleted, json_build_object( 'id', u.id, 'email', u.email, 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS submitter, + )::json AS user, CASE WHEN s.reviewed_by IS NOT NULL THEN json_build_object( 'id', ru.id, @@ -184,7 +185,7 @@ SELECT )::json ELSE NULL::json END AS reviewer FROM submissions s -JOIN users u ON s.submitted_by = u.id +JOIN users u ON s.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id LEFT JOIN users ru ON s.reviewed_by = ru.id LEFT JOIN user_profiles rup ON ru.id = rup.user_id diff --git a/db/schema.sql b/db/schema.sql index 81be834..3837f8d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -119,8 +119,7 @@ CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT uuidv7(), title TEXT NOT NULL, description TEXT, - latest_revision_id UUID, - version_count INT NOT NULL DEFAULT 0, + latest_commit_id UUID, project_status SMALLINT NOT NULL DEFAULT 1, locked_by UUID, is_deleted BOOLEAN NOT NULL DEFAULT false, @@ -129,13 +128,11 @@ CREATE TABLE IF NOT EXISTS projects ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS revisions ( +CREATE TABLE IF NOT EXISTS commits ( id UUID PRIMARY KEY DEFAULT uuidv7(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - version_no INT NOT NULL, snapshot_json JSONB NOT NULL, snapshot_hash TEXT, - parent_id UUID REFERENCES revisions(id), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, edit_summary TEXT, is_deleted BOOLEAN NOT NULL DEFAULT false, @@ -145,12 +142,22 @@ CREATE TABLE IF NOT EXISTS revisions ( CREATE TABLE IF NOT EXISTS submissions ( id UUID PRIMARY KEY DEFAULT uuidv7(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - revision_id UUID NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, - submitted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + commit_id UUID NOT NULL REFERENCES commits(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, status SMALLINT NOT NULL DEFAULT 1, reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, reviewed_at TIMESTAMPTZ, review_note TEXT, - is_deleted BOOLEAN NOT NULL DEFAULT false -); \ No newline at end of file + content TEXT, + is_deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS project_members ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role SMALLINT NOT NULL DEFAULT 3, + invited_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, user_id) +); diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml deleted file mode 100644 index a8e7ca4..0000000 --- a/docker-compose-dev.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - db: - image: postgis/postgis:18-3.6 - container_name: history_db - restart: unless-stopped - environment: - POSTGRES_USER: history - POSTGRES_PASSWORD: secret - POSTGRES_DB: history_map - PGDATA: /var/lib/postgresql/data - ports: - - "5432:5432" - volumes: - - ./pg_data:/var/lib/postgresql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U history -d history_map"] - interval: 5s - timeout: 3s - retries: 5 - - cache: - image: redis:8.6.1-alpine - container_name: history_redis - restart: unless-stopped - ports: - - "6379:6379" - -volumes: - pg_data: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index bc7a105..d5bdf31 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - db: + history_db: image: postgis/postgis:18-3.6 container_name: history_db restart: unless-stopped @@ -10,10 +10,8 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} - PGDATA=/var/lib/postgresql/data - ports: - - "5432:5432" volumes: - - pg_data:/var/lib/postgresql/data + - history_db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s @@ -22,17 +20,17 @@ services: networks: - history-api-project - cache: + history_cache: image: redis:8.6.2-alpine - container_name: history_redis + container_name: history_cache restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - - redis_data:/data + - history_cache_data:/data networks: - history-api-project - migrate: + history_migrate: build: context: . dockerfile_inline: | @@ -40,7 +38,7 @@ services: COPY db/migrations /migrations container_name: history_migrate depends_on: - db: + history_db: condition: service_healthy env_file: - ./assets/resources/.env @@ -48,7 +46,7 @@ services: - sh - -c - | - DB_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable" + DB_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@history_db:5432/${POSTGRES_DB}?sslmode=disable" for i in 1 2 3; do echo "Migration attempt $$i..." @@ -64,16 +62,16 @@ services: networks: - history-api-project - app: + history_app: build: . container_name: history_app restart: unless-stopped depends_on: - migrate: + history_migrate: condition: service_completed_successfully - db: + history_db: condition: service_healthy - cache: + history_cache: condition: service_started env_file: - ./assets/resources/.env @@ -82,14 +80,14 @@ services: networks: - history-api-project - email_worker: + history_email_worker: build: . container_name: history_email_worker restart: unless-stopped depends_on: - db: + history_db: condition: service_healthy - cache: + history_cache: condition: service_started env_file: - ./assets/resources/.env @@ -97,14 +95,14 @@ services: networks: - history-api-project - storage_worker: + history_storage_worker: build: . container_name: history_storage_worker restart: unless-stopped depends_on: - db: + history_db: condition: service_healthy - cache: + history_cache: condition: service_started env_file: - ./assets/resources/.env @@ -113,8 +111,8 @@ services: - history-api-project volumes: - pg_data: - redis_data: + history_db_data: + history_cache_data: networks: history-api-project: \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 37e931b..8c73176 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1622,6 +1622,459 @@ const docTemplate = `{ } } }, + "/projects/{id}/change-owner": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Transfer project ownership to an existing member. Only the current owner can do this.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Transfer project ownership", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "New Owner Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.ChangeOwnerDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/projects/{id}/commits": { + "get": { + "description": "Retrieve all commits for a specific project", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Commits" + ], + "summary": "Get project commits", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new commit and update project's latest commit ID. Only owner/editor allowed.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Commits" + ], + "summary": "Create a new commit", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Commit Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.CreateCommitDto" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/projects/{id}/commits/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update project's latest commit ID to an older commit. Only owner/editor allowed.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Commits" + ], + "summary": "Restore project to a commit", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Restore Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.RestoreCommitDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/projects/{id}/members": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Invite a user to the project with a specific role (EDITOR or VIEWER). Only project owner can do this.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Add a member to project", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Member Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.AddProjectMemberDto" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/projects/{id}/members/{userId}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Change a member's role in the project. Only project owner can do this.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Update member role", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Member User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "description": "Role Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.UpdateProjectMemberDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Remove a user from the project. Only project owner can do this.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Remove a member from project", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Member User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, "/raster-tiles/metadata": { "get": { "description": "Retrieve map metadata", @@ -1783,6 +2236,317 @@ const docTemplate = `{ } } }, + "/submissions": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a list of submissions with filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Search submissions", + "parameters": [ + { + "type": "string", + "name": "created_from", + "in": "query" + }, + { + "type": "string", + "name": "created_to", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "limit", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "name": "order", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "string", + "name": "project_id", + "in": "query" + }, + { + "type": "string", + "name": "reviewed_by", + "in": "query" + }, + { + "maxLength": 200, + "minLength": 2, + "type": "string", + "name": "search", + "in": "query" + }, + { + "enum": [ + "id", + "created_at", + "reviewed_at", + "status" + ], + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "statuses", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "user_ids", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Submit a new submission for a project commit", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Create submission", + "parameters": [ + { + "description": "Submission data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.CreateSubmissionDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/submissions/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get detailed information of a submission", + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Get submission by ID", + "parameters": [ + { + "type": "string", + "description": "Submission ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a submission by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Delete submission", + "parameters": [ + { + "type": "string", + "description": "Submission ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/submissions/{id}/status": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Approve or reject a submission", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Update submission status", + "parameters": [ + { + "type": "string", + "description": "Submission ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.UpdateSubmissionStatusDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, "/tiles/metadata": { "get": { "description": "Retrieve map metadata", @@ -2653,6 +3417,36 @@ const docTemplate = `{ } }, "definitions": { + "history-api_internal_dtos_request.AddProjectMemberDto": { + "type": "object", + "required": [ + "role", + "user_id" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "EDITOR", + "VIEWER" + ] + }, + "user_id": { + "type": "string" + } + } + }, + "history-api_internal_dtos_request.ChangeOwnerDto": { + "type": "object", + "required": [ + "new_owner_id" + ], + "properties": { + "new_owner_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.ChangePasswordDto": { "type": "object", "required": [ @@ -2686,6 +3480,25 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_request.CreateCommitDto": { + "type": "object", + "required": [ + "edit_summary", + "snapshot_json" + ], + "properties": { + "edit_summary": { + "type": "string", + "maxLength": 500 + }, + "snapshot_json": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "history-api_internal_dtos_request.CreateProjectDto": { "type": "object", "required": [ @@ -2709,6 +3522,24 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_request.CreateSubmissionDto": { + "type": "object", + "required": [ + "commit_id", + "project_id" + ], + "properties": { + "commit_id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "project_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.CreateTokenDto": { "type": "object", "required": [ @@ -2810,6 +3641,17 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_request.RestoreCommitDto": { + "type": "object", + "required": [ + "commit_id" + ], + "properties": { + "commit_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.SignInDto": { "type": "object", "required": [ @@ -2915,6 +3757,37 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_request.UpdateProjectMemberDto": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "EDITOR", + "VIEWER" + ] + } + } + }, + "history-api_internal_dtos_request.UpdateSubmissionStatusDto": { + "type": "object", + "required": [ + "review_note", + "status" + ], + "properties": { + "review_note": { + "type": "string", + "minLength": 10 + }, + "status": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.UpdateVerificationStatusDto": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 61bdd9b..12cd960 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1615,6 +1615,459 @@ } } }, + "/projects/{id}/change-owner": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Transfer project ownership to an existing member. Only the current owner can do this.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Transfer project ownership", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "New Owner Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.ChangeOwnerDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/projects/{id}/commits": { + "get": { + "description": "Retrieve all commits for a specific project", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Commits" + ], + "summary": "Get project commits", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new commit and update project's latest commit ID. Only owner/editor allowed.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Commits" + ], + "summary": "Create a new commit", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Commit Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.CreateCommitDto" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/projects/{id}/commits/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update project's latest commit ID to an older commit. Only owner/editor allowed.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Commits" + ], + "summary": "Restore project to a commit", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Restore Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.RestoreCommitDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/projects/{id}/members": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Invite a user to the project with a specific role (EDITOR or VIEWER). Only project owner can do this.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Add a member to project", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Member Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.AddProjectMemberDto" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/projects/{id}/members/{userId}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Change a member's role in the project. Only project owner can do this.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Update member role", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Member User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "description": "Role Data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.UpdateProjectMemberDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Remove a user from the project. Only project owner can do this.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Projects" + ], + "summary": "Remove a member from project", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Member User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, "/raster-tiles/metadata": { "get": { "description": "Retrieve map metadata", @@ -1776,6 +2229,317 @@ } } }, + "/submissions": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a list of submissions with filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Search submissions", + "parameters": [ + { + "type": "string", + "name": "created_from", + "in": "query" + }, + { + "type": "string", + "name": "created_to", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "limit", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "name": "order", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "string", + "name": "project_id", + "in": "query" + }, + { + "type": "string", + "name": "reviewed_by", + "in": "query" + }, + { + "maxLength": 200, + "minLength": 2, + "type": "string", + "name": "search", + "in": "query" + }, + { + "enum": [ + "id", + "created_at", + "reviewed_at", + "status" + ], + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "statuses", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "user_ids", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Submit a new submission for a project commit", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Create submission", + "parameters": [ + { + "description": "Submission data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.CreateSubmissionDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/submissions/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get detailed information of a submission", + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Get submission by ID", + "parameters": [ + { + "type": "string", + "description": "Submission ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a submission by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Delete submission", + "parameters": [ + { + "type": "string", + "description": "Submission ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/submissions/{id}/status": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Approve or reject a submission", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Submission" + ], + "summary": "Update submission status", + "parameters": [ + { + "type": "string", + "description": "Submission ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.UpdateSubmissionStatusDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, "/tiles/metadata": { "get": { "description": "Retrieve map metadata", @@ -2646,6 +3410,36 @@ } }, "definitions": { + "history-api_internal_dtos_request.AddProjectMemberDto": { + "type": "object", + "required": [ + "role", + "user_id" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "EDITOR", + "VIEWER" + ] + }, + "user_id": { + "type": "string" + } + } + }, + "history-api_internal_dtos_request.ChangeOwnerDto": { + "type": "object", + "required": [ + "new_owner_id" + ], + "properties": { + "new_owner_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.ChangePasswordDto": { "type": "object", "required": [ @@ -2679,6 +3473,25 @@ } } }, + "history-api_internal_dtos_request.CreateCommitDto": { + "type": "object", + "required": [ + "edit_summary", + "snapshot_json" + ], + "properties": { + "edit_summary": { + "type": "string", + "maxLength": 500 + }, + "snapshot_json": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "history-api_internal_dtos_request.CreateProjectDto": { "type": "object", "required": [ @@ -2702,6 +3515,24 @@ } } }, + "history-api_internal_dtos_request.CreateSubmissionDto": { + "type": "object", + "required": [ + "commit_id", + "project_id" + ], + "properties": { + "commit_id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "project_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.CreateTokenDto": { "type": "object", "required": [ @@ -2803,6 +3634,17 @@ } } }, + "history-api_internal_dtos_request.RestoreCommitDto": { + "type": "object", + "required": [ + "commit_id" + ], + "properties": { + "commit_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.SignInDto": { "type": "object", "required": [ @@ -2908,6 +3750,37 @@ } } }, + "history-api_internal_dtos_request.UpdateProjectMemberDto": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "EDITOR", + "VIEWER" + ] + } + } + }, + "history-api_internal_dtos_request.UpdateSubmissionStatusDto": { + "type": "object", + "required": [ + "review_note", + "status" + ], + "properties": { + "review_note": { + "type": "string", + "minLength": 10 + }, + "status": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.UpdateVerificationStatusDto": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 85d28c5..4212e26 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,25 @@ basePath: / definitions: + history-api_internal_dtos_request.AddProjectMemberDto: + properties: + role: + enum: + - EDITOR + - VIEWER + type: string + user_id: + type: string + required: + - role + - user_id + type: object + history-api_internal_dtos_request.ChangeOwnerDto: + properties: + new_owner_id: + type: string + required: + - new_owner_id + type: object history-api_internal_dtos_request.ChangePasswordDto: properties: new_password: @@ -23,6 +43,19 @@ definitions: required: - role_ids type: object + history-api_internal_dtos_request.CreateCommitDto: + properties: + edit_summary: + maxLength: 500 + type: string + snapshot_json: + items: + type: integer + type: array + required: + - edit_summary + - snapshot_json + type: object history-api_internal_dtos_request.CreateProjectDto: properties: description: @@ -39,6 +72,18 @@ definitions: required: - title type: object + history-api_internal_dtos_request.CreateSubmissionDto: + properties: + commit_id: + type: string + content: + type: string + project_id: + type: string + required: + - commit_id + - project_id + type: object history-api_internal_dtos_request.CreateTokenDto: properties: email: @@ -108,6 +153,13 @@ definitions: required: - token_id type: object + history-api_internal_dtos_request.RestoreCommitDto: + properties: + commit_id: + type: string + required: + - commit_id + type: object history-api_internal_dtos_request.SignInDto: properties: email: @@ -185,6 +237,27 @@ definitions: maxLength: 255 type: string type: object + history-api_internal_dtos_request.UpdateProjectMemberDto: + properties: + role: + enum: + - EDITOR + - VIEWER + type: string + required: + - role + type: object + history-api_internal_dtos_request.UpdateSubmissionStatusDto: + properties: + review_note: + minLength: 10 + type: string + status: + type: string + required: + - review_note + - status + type: object history-api_internal_dtos_request.UpdateVerificationStatusDto: properties: review_note: @@ -1314,6 +1387,304 @@ paths: summary: Update a project tags: - Projects + /projects/{id}/change-owner: + put: + consumes: + - application/json + description: Transfer project ownership to an existing member. Only the current + owner can do this. + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + - description: New Owner Data + in: body + name: request + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.ChangeOwnerDto' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Transfer project ownership + tags: + - Projects + /projects/{id}/commits: + get: + consumes: + - application/json + description: Retrieve all commits for a specific project + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + summary: Get project commits + tags: + - Commits + post: + consumes: + - application/json + description: Create a new commit and update project's latest commit ID. Only + owner/editor allowed. + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + - description: Commit Data + in: body + name: request + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.CreateCommitDto' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Create a new commit + tags: + - Commits + /projects/{id}/commits/restore: + post: + consumes: + - application/json + description: Update project's latest commit ID to an older commit. Only owner/editor + allowed. + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + - description: Restore Data + in: body + name: request + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.RestoreCommitDto' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Restore project to a commit + tags: + - Commits + /projects/{id}/members: + post: + consumes: + - application/json + description: Invite a user to the project with a specific role (EDITOR or VIEWER). + Only project owner can do this. + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + - description: Member Data + in: body + name: request + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.AddProjectMemberDto' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Add a member to project + tags: + - Projects + /projects/{id}/members/{userId}: + delete: + consumes: + - application/json + description: Remove a user from the project. Only project owner can do this. + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + - description: Member User ID + in: path + name: userId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Remove a member from project + tags: + - Projects + put: + consumes: + - application/json + description: Change a member's role in the project. Only project owner can do + this. + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + - description: Member User ID + in: path + name: userId + required: true + type: string + - description: Role Data + in: body + name: request + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.UpdateProjectMemberDto' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Update member role + tags: + - Projects /raster-tiles/{z}/{x}/{y}: get: description: Fetch vector or raster map tile data by Z, X, Y coordinates @@ -1418,6 +1789,205 @@ paths: summary: Get role by ID tags: - Roles + /submissions: + get: + consumes: + - application/json + description: Get a list of submissions with filters + parameters: + - in: query + name: created_from + type: string + - in: query + name: created_to + type: string + - in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - enum: + - asc + - desc + in: query + name: order + type: string + - in: query + minimum: 1 + name: page + type: integer + - in: query + name: project_id + type: string + - in: query + name: reviewed_by + type: string + - in: query + maxLength: 200 + minLength: 2 + name: search + type: string + - enum: + - id + - created_at + - reviewed_at + - status + in: query + name: sort + type: string + - collectionFormat: csv + in: query + items: + type: string + name: statuses + type: array + - collectionFormat: csv + in: query + items: + type: string + name: user_ids + type: array + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Search submissions + tags: + - Submission + post: + consumes: + - application/json + description: Submit a new submission for a project commit + parameters: + - description: Submission data + in: body + name: body + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.CreateSubmissionDto' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Create submission + tags: + - Submission + /submissions/{id}: + delete: + description: Delete a submission by ID + parameters: + - description: Submission ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Delete submission + tags: + - Submission + get: + description: Get detailed information of a submission + parameters: + - description: Submission ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Get submission by ID + tags: + - Submission + /submissions/{id}/status: + patch: + consumes: + - application/json + description: Approve or reject a submission + parameters: + - description: Submission ID + in: path + name: id + required: true + type: string + - description: Status update data + in: body + name: body + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.UpdateSubmissionStatusDto' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Update submission status + tags: + - Submission /tiles/{z}/{x}/{y}: get: description: Fetch vector or raster map tile data by Z, X, Y coordinates diff --git a/internal/controllers/commitController.go b/internal/controllers/commitController.go new file mode 100644 index 0000000..26464b2 --- /dev/null +++ b/internal/controllers/commitController.go @@ -0,0 +1,137 @@ +package controllers + +import ( + "context" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/services" + "history-api/pkg/validator" + "time" + + "github.com/gofiber/fiber/v3" +) + +type CommitController struct { + service services.CommitService +} + +func NewCommitController(service services.CommitService) *CommitController { + return &CommitController{ + service: service, + } +} + +// CreateCommit godoc +// @Summary Create a new commit +// @Description Create a new commit and update project's latest commit ID. Only owner/editor allowed. +// @Tags Commits +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Param request body request.CreateCommitDto true "Commit Data" +// @Success 201 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/commits [post] +func (h *CommitController) CreateCommit(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + dto := &request.CreateCommitDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + uid := c.Locals("uid").(string) + res, err := h.service.CreateCommit(ctx, uid, projectID, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// RestoreCommit godoc +// @Summary Restore project to a commit +// @Description Update project's latest commit ID to an older commit. Only owner/editor allowed. +// @Tags Commits +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Param request body request.RestoreCommitDto true "Restore Data" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 404 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/commits/restore [post] +func (h *CommitController) RestoreCommit(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + dto := &request.RestoreCommitDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + uid := c.Locals("uid").(string) + err := h.service.RestoreCommit(ctx, uid, projectID, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Message: "Project restored successfully", + }) +} + +// GetProjectCommits godoc +// @Summary Get project commits +// @Description Retrieve all commits for a specific project +// @Tags Commits +// @Accept json +// @Produce json +// @Param id path string true "Project ID" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/commits [get] +func (h *CommitController) GetProjectCommits(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + res, err := h.service.GetProjectCommits(ctx, projectID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} diff --git a/internal/controllers/projectController.go b/internal/controllers/projectController.go index 18bb3be..596a66e 100644 --- a/internal/controllers/projectController.go +++ b/internal/controllers/projectController.go @@ -198,3 +198,170 @@ func (h *ProjectController) DeleteProject(c fiber.Ctx) error { Message: "Project deleted successfully", }) } + +// AddMember godoc +// @Summary Add a member to project +// @Description Invite a user to the project with a specific role (EDITOR or VIEWER). Only project owner can do this. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Param request body request.AddProjectMemberDto true "Member Data" +// @Success 201 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 409 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/members [post] +func (h *ProjectController) AddMember(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + dto := &request.AddProjectMemberDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + uid := c.Locals("uid").(string) + res, err := h.service.AddMember(ctx, uid, projectID, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// UpdateMemberRole godoc +// @Summary Update member role +// @Description Change a member's role in the project. Only project owner can do this. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Param userId path string true "Member User ID" +// @Param request body request.UpdateProjectMemberDto true "Role Data" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 404 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/members/{userId} [put] +func (h *ProjectController) UpdateMemberRole(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + memberUserID := c.Params("userId") + dto := &request.UpdateProjectMemberDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + uid := c.Locals("uid").(string) + res, err := h.service.UpdateMemberRole(ctx, uid, projectID, memberUserID, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// RemoveMember godoc +// @Summary Remove a member from project +// @Description Remove a user from the project. Only project owner can do this. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Param userId path string true "Member User ID" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 404 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/members/{userId} [delete] +func (h *ProjectController) RemoveMember(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + memberUserID := c.Params("userId") + uid := c.Locals("uid").(string) + + err := h.service.RemoveMember(ctx, uid, projectID, memberUserID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Message: "Member removed successfully", + }) +} + +// ChangeOwner godoc +// @Summary Transfer project ownership +// @Description Transfer project ownership to an existing member. Only the current owner can do this. +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Param request body request.ChangeOwnerDto true "New Owner Data" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/change-owner [put] +func (h *ProjectController) ChangeOwner(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + dto := &request.ChangeOwnerDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + uid := c.Locals("uid").(string) + res, err := h.service.ChangeOwner(ctx, uid, projectID, dto.NewOwnerID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} diff --git a/internal/controllers/submissionController.go b/internal/controllers/submissionController.go new file mode 100644 index 0000000..23f7efd --- /dev/null +++ b/internal/controllers/submissionController.go @@ -0,0 +1,213 @@ +package controllers + +import ( + "context" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/services" + "history-api/pkg/validator" + "time" + + "github.com/gofiber/fiber/v3" +) + +type SubmissionController interface { + CreateSubmission(c fiber.Ctx) error + UpdateSubmissionStatus(c fiber.Ctx) error + GetSubmissionByID(c fiber.Ctx) error + SearchSubmissions(c fiber.Ctx) error + DeleteSubmission(c fiber.Ctx) error +} + +type submissionController struct { + submissionService services.SubmissionService +} + +func NewSubmissionController(submissionService services.SubmissionService) SubmissionController { + return &submissionController{ + submissionService: submissionService, + } +} + +// @Summary Create submission +// @Description Submit a new submission for a project commit +// @Tags Submission +// @Accept json +// @Produce json +// @Param body body request.CreateSubmissionDto true "Submission data" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /submissions [post] +func (s *submissionController) CreateSubmission(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + dto := &request.CreateSubmissionDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + res, err := s.submissionService.CreateSubmission(ctx, c.Locals("uid").(string), dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// @Summary Update submission status +// @Description Approve or reject a submission +// @Tags Submission +// @Accept json +// @Produce json +// @Param id path string true "Submission ID" +// @Param body body request.UpdateSubmissionStatusDto true "Status update data" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /submissions/{id}/status [patch] +func (s *submissionController) UpdateSubmissionStatus(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + id := c.Params("id") + uid := c.Locals("uid").(string) + dto := &request.UpdateSubmissionStatusDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + res, err := s.submissionService.UpdateSubmissionStatus(ctx, uid, id, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// @Summary Get submission by ID +// @Description Get detailed information of a submission +// @Tags Submission +// @Produce json +// @Param id path string true "Submission ID" +// @Success 200 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /submissions/{id} [get] +func (s *submissionController) GetSubmissionByID(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + id := c.Params("id") + res, err := s.submissionService.GetSubmissionByID(ctx, id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// @Summary Search submissions +// @Description Get a list of submissions with filters +// @Tags Submission +// @Accept json +// @Produce json +// @Param query query request.SearchSubmissionDto false "Search Query" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /submissions [get] +func (s *submissionController) SearchSubmissions(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + dto := &request.SearchSubmissionDto{} + if err := validator.ValidateQueryDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + res, err := s.submissionService.SearchSubmissions(ctx, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// @Summary Delete submission +// @Description Delete a submission by ID +// @Tags Submission +// @Produce json +// @Param id path string true "Submission ID" +// @Success 200 {object} response.CommonResponse +// @Failure 401 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /submissions/{id} [delete] +func (s *submissionController) DeleteSubmission(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + id := c.Params("id") + + claimsVal := c.Locals("user_claims") + if claimsVal == nil { + return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{ + Status: false, + Message: "Unauthorized", + }) + } + + claims, ok := claimsVal.(*response.JWTClaims) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{ + Status: false, + Message: "Invalid user claims", + }) + } + + err := s.submissionService.DeleteSubmission(ctx, c.Locals("uid").(string), id, claims) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Message: "Submission deleted successfully", + }) +} diff --git a/internal/controllers/userController.go b/internal/controllers/userController.go index c166d89..20c121e 100644 --- a/internal/controllers/userController.go +++ b/internal/controllers/userController.go @@ -46,7 +46,7 @@ func (h *UserController) GetUserCurrent(c fiber.Ctx) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - res, err := h.service.GetUserCurrent(ctx, c.Locals("uid").(string)) + res, err := h.service.GetUserByID(ctx, c.Locals("uid").(string)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ Status: false, diff --git a/internal/controllers/verificationController.go b/internal/controllers/verificationController.go index 74d8585..246fd95 100644 --- a/internal/controllers/verificationController.go +++ b/internal/controllers/verificationController.go @@ -2,8 +2,8 @@ package controllers import ( "context" - "history-api/internal/dtos/response" "history-api/internal/dtos/request" + "history-api/internal/dtos/response" "history-api/internal/services" "history-api/pkg/validator" "time" @@ -63,7 +63,7 @@ func (m *VerificationController) SearchVerification(c fiber.Ctx) error { dto := &request.SearchUserVerificationDto{} if err := validator.ValidateQueryDto(c, dto); err != nil { return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ - Status: false, + Status: false, Errors: err, }) } @@ -137,7 +137,7 @@ func (m *VerificationController) CreateVerification(c fiber.Ctx) error { dto := &request.CreateUserVerificationDto{} if err := validator.ValidateBodyDto(c, dto); err != nil { return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ - Status: false, + Status: false, Errors: err, }) } @@ -148,7 +148,10 @@ func (m *VerificationController) CreateVerification(c fiber.Ctx) error { Message: err.Error(), }) } - return c.Status(fiber.StatusOK).JSON(res) + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) } // @Summary Update application status @@ -170,7 +173,7 @@ func (m *VerificationController) UpdateVerificationStatus(c fiber.Ctx) error { dto := &request.UpdateVerificationStatusDto{} if err := validator.ValidateBodyDto(c, dto); err != nil { return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ - Status: false, + Status: false, Errors: err, }) } @@ -182,5 +185,8 @@ func (m *VerificationController) UpdateVerificationStatus(c fiber.Ctx) error { Message: err.Error(), }) } - return c.Status(fiber.StatusOK).JSON(res) + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) } diff --git a/internal/dtos/request/commit.go b/internal/dtos/request/commit.go new file mode 100644 index 0000000..3537a0d --- /dev/null +++ b/internal/dtos/request/commit.go @@ -0,0 +1,12 @@ +package request + +import "encoding/json" + +type CreateCommitDto struct { + SnapshotJson json.RawMessage `json:"snapshot_json" validate:"required"` + EditSummary string `json:"edit_summary" validate:"required,max=500"` +} + +type RestoreCommitDto struct { + CommitID string `json:"commit_id" validate:"required,uuid"` +} diff --git a/internal/dtos/request/project_member.go b/internal/dtos/request/project_member.go new file mode 100644 index 0000000..8dfcc49 --- /dev/null +++ b/internal/dtos/request/project_member.go @@ -0,0 +1,14 @@ +package request + +type AddProjectMemberDto struct { + UserID string `json:"user_id" validate:"required,uuid"` + Role string `json:"role" validate:"required,oneof=EDITOR VIEWER"` +} + +type UpdateProjectMemberDto struct { + Role string `json:"role" validate:"required,oneof=EDITOR VIEWER"` +} + +type ChangeOwnerDto struct { + NewOwnerID string `json:"new_owner_id" validate:"required,uuid"` +} diff --git a/internal/dtos/request/submissionRequest.go b/internal/dtos/request/submissionRequest.go new file mode 100644 index 0000000..e4135e3 --- /dev/null +++ b/internal/dtos/request/submissionRequest.go @@ -0,0 +1,26 @@ +package request + +import "time" + +type CreateSubmissionDto struct { + ProjectID string `json:"project_id" validate:"required"` + CommitID string `json:"commit_id" validate:"required"` + Content string `json:"content"` +} + +type UpdateSubmissionStatusDto struct { + Status string `json:"status" validate:"required"` + ReviewNote string `json:"review_note" validate:"required,min=10"` +} + +type SearchSubmissionDto struct { + PaginationDto + ProjectID string `json:"project_id" query:"project_id" validate:"omitempty,uuid"` + Sort string `json:"sort" query:"sort" validate:"omitempty,oneof=id created_at reviewed_at status"` + Search string `json:"search" query:"search" validate:"omitempty,min=2,max=200"` + UserIDs []string `json:"user_ids" query:"user_ids" validate:"omitempty,dive,uuid"` + Statuses []string `json:"statuses" query:"statuses" validate:"omitempty,dive,oneof=PENDING APPROVED REJECTED"` + ReviewedBy *string `json:"reviewed_by" query:"reviewed_by" validate:"omitempty,uuid"` + CreatedFrom *time.Time `json:"created_from" query:"created_from"` + CreatedTo *time.Time `json:"created_to" query:"created_to"` +} diff --git a/internal/dtos/response/commit.go b/internal/dtos/response/commit.go new file mode 100644 index 0000000..b7b773b --- /dev/null +++ b/internal/dtos/response/commit.go @@ -0,0 +1,17 @@ +package response + +import ( + "encoding/json" + "time" +) + +type CommitResponse struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + SnapshotJson json.RawMessage `json:"snapshot_json"` + SnapshotHash string `json:"snapshot_hash"` + UserID string `json:"user_id"` + EditSummary string `json:"edit_summary"` + CreatedAt *time.Time `json:"created_at"` + User *UserSimpleResponse `json:"user,omitempty"` +} diff --git a/internal/dtos/response/project.go b/internal/dtos/response/project.go index ced7acf..8ef4320 100644 --- a/internal/dtos/response/project.go +++ b/internal/dtos/response/project.go @@ -2,19 +2,31 @@ package response import "time" -type ProjectResponse struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - LatestRevisionID *string `json:"latest_revision_id,omitempty"` - VersionCount int32 `json:"version_count"` - ProjectStatus string `json:"project_status"` - LockedBy *string `json:"locked_by,omitempty"` - IsDeleted bool `json:"is_deleted"` - UserID string `json:"user_id"` - CreatedAt *time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at"` - User *UserSimpleResponse `json:"user,omitempty"` - CommitIds []string `json:"commit_ids"` - SubmissionIds []string `json:"submission_ids"` +type CommitSimpleResponse struct { + ID string `json:"id"` + EditSummary string `json:"edit_summary"` +} + +type MemberSimpleResponse struct { + UserID string `json:"user_id"` + Role string `json:"role"` + DisplayName string `json:"display_name"` + AvatarUrl string `json:"avatar_url"` +} + +type ProjectResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + LatestCommitID *string `json:"latest_commit_id,omitempty"` + ProjectStatus string `json:"project_status"` + LockedBy *string `json:"locked_by,omitempty"` + IsDeleted bool `json:"is_deleted"` + UserID string `json:"user_id"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + User *UserSimpleResponse `json:"user,omitempty"` + Commits []CommitSimpleResponse `json:"commits"` + SubmissionIds []string `json:"submission_ids"` + Members []MemberSimpleResponse `json:"members"` } diff --git a/internal/dtos/response/submissionResponse.go b/internal/dtos/response/submissionResponse.go new file mode 100644 index 0000000..c8cf51e --- /dev/null +++ b/internal/dtos/response/submissionResponse.go @@ -0,0 +1,18 @@ +package response + +import "time" + +type SubmissionResponse struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + CommitID string `json:"commit_id"` + UserID string `json:"user_id"` + CreatedAt *time.Time `json:"created_at"` + Status string `json:"status"` + ReviewedBy *string `json:"reviewed_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + ReviewNote *string `json:"review_note,omitempty"` + Content *string `json:"content,omitempty"` + User *UserSimpleResponse `json:"user"` + Reviewer *UserSimpleResponse `json:"reviewer,omitempty"` +} diff --git a/internal/gen/sqlc/commit.sql.go b/internal/gen/sqlc/commit.sql.go new file mode 100644 index 0000000..90a3871 --- /dev/null +++ b/internal/gen/sqlc/commit.sql.go @@ -0,0 +1,206 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: commit.sql + +package sqlc + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createCommit = `-- name: CreateCommit :one +INSERT INTO commits ( + project_id, snapshot_json, snapshot_hash, user_id, edit_summary +) VALUES ( + $1, $2, $3, $4, $5 +) +RETURNING id, project_id, snapshot_json, snapshot_hash, user_id, edit_summary, is_deleted, created_at +` + +type CreateCommitParams struct { + ProjectID pgtype.UUID `json:"project_id"` + SnapshotJson json.RawMessage `json:"snapshot_json"` + SnapshotHash pgtype.Text `json:"snapshot_hash"` + UserID pgtype.UUID `json:"user_id"` + EditSummary pgtype.Text `json:"edit_summary"` +} + +func (q *Queries) CreateCommit(ctx context.Context, arg CreateCommitParams) (Commit, error) { + row := q.db.QueryRow(ctx, createCommit, + arg.ProjectID, + arg.SnapshotJson, + arg.SnapshotHash, + arg.UserID, + arg.EditSummary, + ) + var i Commit + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.SnapshotJson, + &i.SnapshotHash, + &i.UserID, + &i.EditSummary, + &i.IsDeleted, + &i.CreatedAt, + ) + return i, err +} + +const deleteCommit = `-- name: DeleteCommit :exec +UPDATE commits +SET is_deleted = true +WHERE id = $1 +` + +func (q *Queries) DeleteCommit(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteCommit, id) + return err +} + +const getCommitById = `-- name: GetCommitById :one +SELECT id, project_id, snapshot_json, snapshot_hash, user_id, edit_summary, is_deleted, created_at +FROM commits +WHERE id = $1 AND is_deleted = false +` + +func (q *Queries) GetCommitById(ctx context.Context, id pgtype.UUID) (Commit, error) { + row := q.db.QueryRow(ctx, getCommitById, id) + var i Commit + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.SnapshotJson, + &i.SnapshotHash, + &i.UserID, + &i.EditSummary, + &i.IsDeleted, + &i.CreatedAt, + ) + return i, err +} + +const getCommitsByIDs = `-- name: GetCommitsByIDs :many +SELECT id, project_id, snapshot_json, snapshot_hash, user_id, edit_summary, is_deleted, created_at FROM commits WHERE id = ANY($1::uuid[]) AND is_deleted = false +` + +func (q *Queries) GetCommitsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Commit, error) { + rows, err := q.db.Query(ctx, getCommitsByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Commit{} + for rows.Next() { + var i Commit + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.SnapshotJson, + &i.SnapshotHash, + &i.UserID, + &i.EditSummary, + &i.IsDeleted, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getCommitsByProjectID = `-- name: GetCommitsByProjectID :many +SELECT id, project_id, snapshot_json, snapshot_hash, user_id, edit_summary, is_deleted, created_at +FROM commits +WHERE project_id = $1 AND is_deleted = false +ORDER BY created_at DESC +` + +func (q *Queries) GetCommitsByProjectID(ctx context.Context, projectID pgtype.UUID) ([]Commit, error) { + rows, err := q.db.Query(ctx, getCommitsByProjectID, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Commit{} + for rows.Next() { + var i Commit + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.SnapshotJson, + &i.SnapshotHash, + &i.UserID, + &i.EditSummary, + &i.IsDeleted, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchCommits = `-- name: SearchCommits :many +SELECT id, project_id, snapshot_json, snapshot_hash, user_id, edit_summary, is_deleted, created_at +FROM commits +WHERE is_deleted = false + AND ($1::uuid IS NULL OR project_id = $1) + AND ($2::uuid IS NULL OR user_id = $2) + AND ($3::uuid IS NULL OR id < $3::uuid) +ORDER BY created_at DESC +LIMIT $4 +` + +type SearchCommitsParams struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + CursorID pgtype.UUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) SearchCommits(ctx context.Context, arg SearchCommitsParams) ([]Commit, error) { + rows, err := q.db.Query(ctx, searchCommits, + arg.ProjectID, + arg.UserID, + arg.CursorID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Commit{} + for rows.Next() { + var i Commit + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.SnapshotJson, + &i.SnapshotHash, + &i.UserID, + &i.EditSummary, + &i.IsDeleted, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/gen/sqlc/models.go b/internal/gen/sqlc/models.go index f1f3697..2069360 100644 --- a/internal/gen/sqlc/models.go +++ b/internal/gen/sqlc/models.go @@ -10,6 +10,17 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Commit struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + SnapshotJson json.RawMessage `json:"snapshot_json"` + SnapshotHash pgtype.Text `json:"snapshot_hash"` + UserID pgtype.UUID `json:"user_id"` + EditSummary pgtype.Text `json:"edit_summary"` + IsDeleted bool `json:"is_deleted"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Entity struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -56,30 +67,24 @@ type Media struct { } type Project struct { - ID pgtype.UUID `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LatestRevisionID pgtype.UUID `json:"latest_revision_id"` - VersionCount int32 `json:"version_count"` - ProjectStatus int16 `json:"project_status"` - LockedBy pgtype.UUID `json:"locked_by"` - IsDeleted bool `json:"is_deleted"` - UserID pgtype.UUID `json:"user_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` + ProjectStatus int16 `json:"project_status"` + LockedBy pgtype.UUID `json:"locked_by"` + IsDeleted bool `json:"is_deleted"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type Revision struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - VersionNo int32 `json:"version_no"` - SnapshotJson json.RawMessage `json:"snapshot_json"` - SnapshotHash pgtype.Text `json:"snapshot_hash"` - ParentID pgtype.UUID `json:"parent_id"` - UserID pgtype.UUID `json:"user_id"` - EditSummary pgtype.Text `json:"edit_summary"` - IsDeleted bool `json:"is_deleted"` - CreatedAt pgtype.Timestamptz `json:"created_at"` +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role int16 `json:"role"` + InvitedBy pgtype.UUID `json:"invited_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Role struct { @@ -91,16 +96,17 @@ type Role struct { } type Submission struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - RevisionID pgtype.UUID `json:"revision_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` - SubmittedAt pgtype.Timestamptz `json:"submitted_at"` - Status int16 `json:"status"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` - ReviewNote pgtype.Text `json:"review_note"` - IsDeleted bool `json:"is_deleted"` + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + CommitID pgtype.UUID `json:"commit_id"` + UserID pgtype.UUID `json:"user_id"` + Status int16 `json:"status"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + ReviewNote pgtype.Text `json:"review_note"` + Content pgtype.Text `json:"content"` + IsDeleted bool `json:"is_deleted"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type User struct { diff --git a/internal/gen/sqlc/project.sql.go b/internal/gen/sqlc/project.sql.go index 4fe3ef3..e96702c 100644 --- a/internal/gen/sqlc/project.sql.go +++ b/internal/gen/sqlc/project.sql.go @@ -11,13 +11,80 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const addProjectMember = `-- name: AddProjectMember :one +INSERT INTO project_members ( + project_id, user_id, role, invited_by +) VALUES ( + $1, $2, $3, $4 +) +RETURNING project_id, user_id, role, invited_by, created_at +` + +type AddProjectMemberParams struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role int16 `json:"role"` + InvitedBy pgtype.UUID `json:"invited_by"` +} + +func (q *Queries) AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (ProjectMember, error) { + row := q.db.QueryRow(ctx, addProjectMember, + arg.ProjectID, + arg.UserID, + arg.Role, + arg.InvitedBy, + ) + var i ProjectMember + err := row.Scan( + &i.ProjectID, + &i.UserID, + &i.Role, + &i.InvitedBy, + &i.CreatedAt, + ) + return i, err +} + +const changeProjectOwner = `-- name: ChangeProjectOwner :exec +UPDATE projects +SET user_id = $2, updated_at = NOW() +WHERE id = $1 AND is_deleted = false +` + +type ChangeProjectOwnerParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) ChangeProjectOwner(ctx context.Context, arg ChangeProjectOwnerParams) error { + _, err := q.db.Exec(ctx, changeProjectOwner, arg.ID, arg.UserID) + return err +} + +const checkProjectPermission = `-- name: CheckProjectPermission :one +SELECT role FROM project_members +WHERE project_id = $1 AND user_id = $2 +` + +type CheckProjectPermissionParams struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) CheckProjectPermission(ctx context.Context, arg CheckProjectPermissionParams) (int16, error) { + row := q.db.QueryRow(ctx, checkProjectPermission, arg.ProjectID, arg.UserID) + var role int16 + err := row.Scan(&role) + return role, err +} + const countProjects = `-- name: CountProjects :one SELECT count(*) FROM projects p WHERE p.is_deleted = false AND ( - $1::text[] IS NULL - OR p.project_status = ANY($1::text[]) + $1::smallint[] IS NULL + OR p.project_status = ANY($1::smallint[]) ) AND ($2::uuid[] IS NULL OR p.user_id = ANY($2::uuid[])) AND ( @@ -30,7 +97,7 @@ WHERE p.is_deleted = false ` type CountProjectsParams struct { - Statuses []string `json:"statuses"` + Statuses []int16 `json:"statuses"` UserIds []pgtype.UUID `json:"user_ids"` SearchText pgtype.Text `json:"search_text"` CreatedFrom pgtype.Timestamptz `json:"created_from"` @@ -57,7 +124,7 @@ INSERT INTO projects ( $1, $2, $3, $4 ) RETURNING - id, title, description, latest_revision_id, version_count, project_status, locked_by, is_deleted, user_id, created_at, updated_at, + id, title, description, latest_commit_id, project_status, locked_by, is_deleted, user_id, created_at, updated_at, json_build_object( 'id', u.id, 'email', u.email, @@ -65,8 +132,9 @@ RETURNING 'full_name', up.full_name, 'avatar_url', up.avatar_url )::json AS user, - '{}'::uuid[] AS commit_ids, - '{}'::uuid[] AS submission_ids + '[]'::json AS commits, + '{}'::uuid[] AS submission_ids, + '[]'::json AS members ` type CreateProjectParams struct { @@ -77,20 +145,20 @@ type CreateProjectParams struct { } type CreateProjectRow struct { - ID pgtype.UUID `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LatestRevisionID pgtype.UUID `json:"latest_revision_id"` - VersionCount int32 `json:"version_count"` - ProjectStatus int16 `json:"project_status"` - LockedBy pgtype.UUID `json:"locked_by"` - IsDeleted bool `json:"is_deleted"` - UserID pgtype.UUID `json:"user_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - User []byte `json:"user"` - CommitIds []pgtype.UUID `json:"commit_ids"` - SubmissionIds []pgtype.UUID `json:"submission_ids"` + ID pgtype.UUID `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` + ProjectStatus int16 `json:"project_status"` + LockedBy pgtype.UUID `json:"locked_by"` + IsDeleted bool `json:"is_deleted"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + User []byte `json:"user"` + Commits []byte `json:"commits"` + SubmissionIds []pgtype.UUID `json:"submission_ids"` + Members []byte `json:"members"` } func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (CreateProjectRow, error) { @@ -105,8 +173,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (C &i.ID, &i.Title, &i.Description, - &i.LatestRevisionID, - &i.VersionCount, + &i.LatestCommitID, &i.ProjectStatus, &i.LockedBy, &i.IsDeleted, @@ -114,8 +181,9 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (C &i.CreatedAt, &i.UpdatedAt, &i.User, - &i.CommitIds, + &i.Commits, &i.SubmissionIds, + &i.Members, ) return i, err } @@ -134,11 +202,12 @@ func (q *Queries) DeleteProject(ctx context.Context, id pgtype.UUID) error { const getProjectById = `-- name: GetProjectById :one SELECT - p.id, p.title, p.description, p.latest_revision_id, p.version_count, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, + p.id, p.title, p.description, p.latest_commit_id, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, COALESCE( - (SELECT array_agg(id) FROM revisions WHERE project_id = p.id), - '{}' - )::uuid[] AS commit_ids, + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), + '[]' + )::json AS commits, COALESCE( (SELECT array_agg(id) FROM submissions WHERE project_id = p.id), '{}' @@ -149,7 +218,18 @@ SELECT 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS user + )::json AS user, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = p.id), + '[]' + )::json AS members FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id @@ -157,20 +237,20 @@ WHERE p.id = $1 AND p.is_deleted = false ` type GetProjectByIdRow struct { - ID pgtype.UUID `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LatestRevisionID pgtype.UUID `json:"latest_revision_id"` - VersionCount int32 `json:"version_count"` - ProjectStatus int16 `json:"project_status"` - LockedBy pgtype.UUID `json:"locked_by"` - IsDeleted bool `json:"is_deleted"` - UserID pgtype.UUID `json:"user_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - CommitIds []pgtype.UUID `json:"commit_ids"` - SubmissionIds []pgtype.UUID `json:"submission_ids"` - User []byte `json:"user"` + ID pgtype.UUID `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` + ProjectStatus int16 `json:"project_status"` + LockedBy pgtype.UUID `json:"locked_by"` + IsDeleted bool `json:"is_deleted"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Commits []byte `json:"commits"` + SubmissionIds []pgtype.UUID `json:"submission_ids"` + User []byte `json:"user"` + Members []byte `json:"members"` } func (q *Queries) GetProjectById(ctx context.Context, id pgtype.UUID) (GetProjectByIdRow, error) { @@ -180,25 +260,29 @@ func (q *Queries) GetProjectById(ctx context.Context, id pgtype.UUID) (GetProjec &i.ID, &i.Title, &i.Description, - &i.LatestRevisionID, - &i.VersionCount, + &i.LatestCommitID, &i.ProjectStatus, &i.LockedBy, &i.IsDeleted, &i.UserID, &i.CreatedAt, &i.UpdatedAt, - &i.CommitIds, + &i.Commits, &i.SubmissionIds, &i.User, + &i.Members, ) return i, err } const getProjectsByIDs = `-- name: GetProjectsByIDs :many SELECT - p.id, p.title, p.description, p.latest_revision_id, p.version_count, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, - COALESCE((SELECT array_agg(id) FROM revisions WHERE project_id = p.id), '{}')::uuid[] AS commit_ids, + p.id, p.title, p.description, p.latest_commit_id, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, + COALESCE( + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), + '[]' + )::json AS commits, COALESCE((SELECT array_agg(id) FROM submissions WHERE project_id = p.id), '{}')::uuid[] AS submission_ids, json_build_object( 'id', u.id, @@ -206,7 +290,18 @@ SELECT 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS user + )::json AS user, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = p.id), + '[]' + )::json AS members FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id @@ -214,20 +309,20 @@ WHERE p.id = ANY($1::uuid[]) AND p.is_deleted = false ` type GetProjectsByIDsRow struct { - ID pgtype.UUID `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LatestRevisionID pgtype.UUID `json:"latest_revision_id"` - VersionCount int32 `json:"version_count"` - ProjectStatus int16 `json:"project_status"` - LockedBy pgtype.UUID `json:"locked_by"` - IsDeleted bool `json:"is_deleted"` - UserID pgtype.UUID `json:"user_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - CommitIds []pgtype.UUID `json:"commit_ids"` - SubmissionIds []pgtype.UUID `json:"submission_ids"` - User []byte `json:"user"` + ID pgtype.UUID `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` + ProjectStatus int16 `json:"project_status"` + LockedBy pgtype.UUID `json:"locked_by"` + IsDeleted bool `json:"is_deleted"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Commits []byte `json:"commits"` + SubmissionIds []pgtype.UUID `json:"submission_ids"` + User []byte `json:"user"` + Members []byte `json:"members"` } func (q *Queries) GetProjectsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]GetProjectsByIDsRow, error) { @@ -243,17 +338,17 @@ func (q *Queries) GetProjectsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) &i.ID, &i.Title, &i.Description, - &i.LatestRevisionID, - &i.VersionCount, + &i.LatestCommitID, &i.ProjectStatus, &i.LockedBy, &i.IsDeleted, &i.UserID, &i.CreatedAt, &i.UpdatedAt, - &i.CommitIds, + &i.Commits, &i.SubmissionIds, &i.User, + &i.Members, ); err != nil { return nil, err } @@ -267,11 +362,12 @@ func (q *Queries) GetProjectsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) const getProjectsByUserId = `-- name: GetProjectsByUserId :many SELECT - p.id, p.title, p.description, p.latest_revision_id, p.version_count, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, + p.id, p.title, p.description, p.latest_commit_id, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, COALESCE( - (SELECT array_agg(id) FROM revisions WHERE project_id = p.id), - '{}' - )::uuid[] AS commit_ids, + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), + '[]' + )::json AS commits, COALESCE( (SELECT array_agg(id) FROM submissions WHERE project_id = p.id), '{}' @@ -282,11 +378,22 @@ SELECT 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS user + )::json AS user, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = p.id), + '[]' + )::json AS members FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id -WHERE p.user_id = $1 +WHERE (p.user_id = $1 OR EXISTS (SELECT 1 FROM project_members pm2 WHERE pm2.project_id = p.id AND pm2.user_id = $1)) AND p.is_deleted = false AND ($2::uuid IS NULL OR p.id < $2::uuid) ORDER BY p.updated_at DESC @@ -300,20 +407,20 @@ type GetProjectsByUserIdParams struct { } type GetProjectsByUserIdRow struct { - ID pgtype.UUID `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LatestRevisionID pgtype.UUID `json:"latest_revision_id"` - VersionCount int32 `json:"version_count"` - ProjectStatus int16 `json:"project_status"` - LockedBy pgtype.UUID `json:"locked_by"` - IsDeleted bool `json:"is_deleted"` - UserID pgtype.UUID `json:"user_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - CommitIds []pgtype.UUID `json:"commit_ids"` - SubmissionIds []pgtype.UUID `json:"submission_ids"` - User []byte `json:"user"` + ID pgtype.UUID `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` + ProjectStatus int16 `json:"project_status"` + LockedBy pgtype.UUID `json:"locked_by"` + IsDeleted bool `json:"is_deleted"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Commits []byte `json:"commits"` + SubmissionIds []pgtype.UUID `json:"submission_ids"` + User []byte `json:"user"` + Members []byte `json:"members"` } func (q *Queries) GetProjectsByUserId(ctx context.Context, arg GetProjectsByUserIdParams) ([]GetProjectsByUserIdRow, error) { @@ -329,17 +436,17 @@ func (q *Queries) GetProjectsByUserId(ctx context.Context, arg GetProjectsByUser &i.ID, &i.Title, &i.Description, - &i.LatestRevisionID, - &i.VersionCount, + &i.LatestCommitID, &i.ProjectStatus, &i.LockedBy, &i.IsDeleted, &i.UserID, &i.CreatedAt, &i.UpdatedAt, - &i.CommitIds, + &i.Commits, &i.SubmissionIds, &i.User, + &i.Members, ); err != nil { return nil, err } @@ -351,13 +458,29 @@ func (q *Queries) GetProjectsByUserId(ctx context.Context, arg GetProjectsByUser return items, nil } +const removeProjectMember = `-- name: RemoveProjectMember :exec +DELETE FROM project_members +WHERE project_id = $1 AND user_id = $2 +` + +type RemoveProjectMemberParams struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) error { + _, err := q.db.Exec(ctx, removeProjectMember, arg.ProjectID, arg.UserID) + return err +} + const searchProjects = `-- name: SearchProjects :many SELECT - p.id, p.title, p.description, p.latest_revision_id, p.version_count, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, + p.id, p.title, p.description, p.latest_commit_id, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, COALESCE( - (SELECT array_agg(id) FROM revisions WHERE project_id = p.id), - '{}' - )::uuid[] AS commit_ids, + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), + '[]' + )::json AS commits, COALESCE( (SELECT array_agg(id) FROM submissions WHERE project_id = p.id), '{}' @@ -368,14 +491,25 @@ SELECT 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS user + )::json AS user, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = p.id), + '[]' + )::json AS members FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id WHERE p.is_deleted = false AND ( - $1::text[] IS NULL - OR p.project_status = ANY($1::text[]) + $1::smallint[] IS NULL + OR p.project_status = ANY($1::smallint[]) ) AND ($2::uuid[] IS NULL OR p.user_id = ANY($2::uuid[])) AND ( @@ -398,7 +532,7 @@ OFFSET $8 ` type SearchProjectsParams struct { - Statuses []string `json:"statuses"` + Statuses []int16 `json:"statuses"` UserIds []pgtype.UUID `json:"user_ids"` SearchText pgtype.Text `json:"search_text"` CreatedFrom pgtype.Timestamptz `json:"created_from"` @@ -410,20 +544,20 @@ type SearchProjectsParams struct { } type SearchProjectsRow struct { - ID pgtype.UUID `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LatestRevisionID pgtype.UUID `json:"latest_revision_id"` - VersionCount int32 `json:"version_count"` - ProjectStatus int16 `json:"project_status"` - LockedBy pgtype.UUID `json:"locked_by"` - IsDeleted bool `json:"is_deleted"` - UserID pgtype.UUID `json:"user_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - CommitIds []pgtype.UUID `json:"commit_ids"` - SubmissionIds []pgtype.UUID `json:"submission_ids"` - User []byte `json:"user"` + ID pgtype.UUID `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` + ProjectStatus int16 `json:"project_status"` + LockedBy pgtype.UUID `json:"locked_by"` + IsDeleted bool `json:"is_deleted"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Commits []byte `json:"commits"` + SubmissionIds []pgtype.UUID `json:"submission_ids"` + User []byte `json:"user"` + Members []byte `json:"members"` } func (q *Queries) SearchProjects(ctx context.Context, arg SearchProjectsParams) ([]SearchProjectsRow, error) { @@ -449,17 +583,17 @@ func (q *Queries) SearchProjects(ctx context.Context, arg SearchProjectsParams) &i.ID, &i.Title, &i.Description, - &i.LatestRevisionID, - &i.VersionCount, + &i.LatestCommitID, &i.ProjectStatus, &i.LockedBy, &i.IsDeleted, &i.UserID, &i.CreatedAt, &i.UpdatedAt, - &i.CommitIds, + &i.Commits, &i.SubmissionIds, &i.User, + &i.Members, ); err != nil { return nil, err } @@ -471,22 +605,37 @@ func (q *Queries) SearchProjects(ctx context.Context, arg SearchProjectsParams) return items, nil } +const updateLatestCommit = `-- name: UpdateLatestCommit :exec +UPDATE projects +SET latest_commit_id = $2, updated_at = NOW() +WHERE id = $1 AND is_deleted = false +` + +type UpdateLatestCommitParams struct { + ID pgtype.UUID `json:"id"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` +} + +func (q *Queries) UpdateLatestCommit(ctx context.Context, arg UpdateLatestCommitParams) error { + _, err := q.db.Exec(ctx, updateLatestCommit, arg.ID, arg.LatestCommitID) + return err +} + const updateProject = `-- name: UpdateProject :one UPDATE projects SET title = COALESCE($1, title), description = COALESCE($2, description), - latest_revision_id = COALESCE($3, latest_revision_id), - version_count = COALESCE($4, version_count), - project_status = COALESCE($5, status), - locked_by = COALESCE($6, locked_by), + latest_commit_id = COALESCE($3, latest_commit_id), + project_status = COALESCE($4, status), + locked_by = COALESCE($5, locked_by), updated_at = NOW() FROM projects p JOIN users u ON p.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id -WHERE p.id = $7 AND p.is_deleted = false +WHERE p.id = $6 AND p.is_deleted = false RETURNING - p.id, p.title, p.description, p.latest_revision_id, p.version_count, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, + p.id, p.title, p.description, p.latest_commit_id, p.project_status, p.locked_by, p.is_deleted, p.user_id, p.created_at, p.updated_at, json_build_object( 'id', u.id, 'email', u.email, @@ -494,43 +643,56 @@ RETURNING 'full_name', up.full_name, 'avatar_url', up.avatar_url )::json AS user, - COALESCE((SELECT array_agg(id) FROM revisions WHERE project_id = projects.id), '{}')::uuid[] AS commit_ids, - COALESCE((SELECT array_agg(id) FROM submissions WHERE project_id = projects.id), '{}')::uuid[] AS submission_ids + COALESCE( + (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) + FROM commits c WHERE c.project_id = projects.id AND c.is_deleted = false), + '[]' + )::json AS commits, + COALESCE((SELECT array_agg(id) FROM submissions WHERE project_id = projects.id), '{}')::uuid[] AS submission_ids, + COALESCE( + (SELECT json_agg(json_build_object( + 'user_id', pm.user_id, 'role', pm.role, + 'display_name', mup.display_name, 'avatar_url', mup.avatar_url + ) ORDER BY pm.role ASC, pm.created_at ASC) + FROM project_members pm + JOIN users mu ON pm.user_id = mu.id + LEFT JOIN user_profiles mup ON mu.id = mup.user_id + WHERE pm.project_id = projects.id), + '[]' + )::json AS members ` type UpdateProjectParams struct { - Title pgtype.Text `json:"title"` - Description pgtype.Text `json:"description"` - LatestRevisionID pgtype.UUID `json:"latest_revision_id"` - VersionCount pgtype.Int4 `json:"version_count"` - Status pgtype.Int2 `json:"status"` - LockedBy pgtype.UUID `json:"locked_by"` - ID pgtype.UUID `json:"id"` + Title pgtype.Text `json:"title"` + Description pgtype.Text `json:"description"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` + Status pgtype.Int2 `json:"status"` + LockedBy pgtype.UUID `json:"locked_by"` + ID pgtype.UUID `json:"id"` } type UpdateProjectRow struct { - ID pgtype.UUID `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LatestRevisionID pgtype.UUID `json:"latest_revision_id"` - VersionCount int32 `json:"version_count"` - ProjectStatus int16 `json:"project_status"` - LockedBy pgtype.UUID `json:"locked_by"` - IsDeleted bool `json:"is_deleted"` - UserID pgtype.UUID `json:"user_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - User []byte `json:"user"` - CommitIds []pgtype.UUID `json:"commit_ids"` - SubmissionIds []pgtype.UUID `json:"submission_ids"` + ID pgtype.UUID `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + LatestCommitID pgtype.UUID `json:"latest_commit_id"` + ProjectStatus int16 `json:"project_status"` + LockedBy pgtype.UUID `json:"locked_by"` + IsDeleted bool `json:"is_deleted"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + User []byte `json:"user"` + Commits []byte `json:"commits"` + SubmissionIds []pgtype.UUID `json:"submission_ids"` + Members []byte `json:"members"` } func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (UpdateProjectRow, error) { row := q.db.QueryRow(ctx, updateProject, arg.Title, arg.Description, - arg.LatestRevisionID, - arg.VersionCount, + arg.LatestCommitID, arg.Status, arg.LockedBy, arg.ID, @@ -540,8 +702,7 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (U &i.ID, &i.Title, &i.Description, - &i.LatestRevisionID, - &i.VersionCount, + &i.LatestCommitID, &i.ProjectStatus, &i.LockedBy, &i.IsDeleted, @@ -549,8 +710,35 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (U &i.CreatedAt, &i.UpdatedAt, &i.User, - &i.CommitIds, + &i.Commits, &i.SubmissionIds, + &i.Members, + ) + return i, err +} + +const updateProjectMemberRole = `-- name: UpdateProjectMemberRole :one +UPDATE project_members +SET role = $3 +WHERE project_id = $1 AND user_id = $2 +RETURNING project_id, user_id, role, invited_by, created_at +` + +type UpdateProjectMemberRoleParams struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role int16 `json:"role"` +} + +func (q *Queries) UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (ProjectMember, error) { + row := q.db.QueryRow(ctx, updateProjectMemberRole, arg.ProjectID, arg.UserID, arg.Role) + var i ProjectMember + err := row.Scan( + &i.ProjectID, + &i.UserID, + &i.Role, + &i.InvitedBy, + &i.CreatedAt, ) return i, err } diff --git a/internal/gen/sqlc/revision.sql.go b/internal/gen/sqlc/revision.sql.go deleted file mode 100644 index 09e19d3..0000000 --- a/internal/gen/sqlc/revision.sql.go +++ /dev/null @@ -1,182 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: revision.sql - -package sqlc - -import ( - "context" - "encoding/json" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createRevision = `-- name: CreateRevision :one -INSERT INTO revisions ( - project_id, version_no, snapshot_json, snapshot_hash, parent_id, user_id, edit_summary -) VALUES ( - $1, $2, $3, $4, $5, $6, $7 -) -RETURNING id, project_id, version_no, snapshot_json, snapshot_hash, parent_id, user_id, edit_summary, is_deleted, created_at -` - -type CreateRevisionParams struct { - ProjectID pgtype.UUID `json:"project_id"` - VersionNo int32 `json:"version_no"` - SnapshotJson json.RawMessage `json:"snapshot_json"` - SnapshotHash pgtype.Text `json:"snapshot_hash"` - ParentID pgtype.UUID `json:"parent_id"` - UserID pgtype.UUID `json:"user_id"` - EditSummary pgtype.Text `json:"edit_summary"` -} - -func (q *Queries) CreateRevision(ctx context.Context, arg CreateRevisionParams) (Revision, error) { - row := q.db.QueryRow(ctx, createRevision, - arg.ProjectID, - arg.VersionNo, - arg.SnapshotJson, - arg.SnapshotHash, - arg.ParentID, - arg.UserID, - arg.EditSummary, - ) - var i Revision - err := row.Scan( - &i.ID, - &i.ProjectID, - &i.VersionNo, - &i.SnapshotJson, - &i.SnapshotHash, - &i.ParentID, - &i.UserID, - &i.EditSummary, - &i.IsDeleted, - &i.CreatedAt, - ) - return i, err -} - -const deleteRevision = `-- name: DeleteRevision :exec -UPDATE revisions -SET is_deleted = true -WHERE id = $1 -` - -func (q *Queries) DeleteRevision(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, deleteRevision, id) - return err -} - -const getRevisionById = `-- name: GetRevisionById :one -SELECT id, project_id, version_no, snapshot_json, snapshot_hash, parent_id, user_id, edit_summary, is_deleted, created_at -FROM revisions -WHERE id = $1 AND is_deleted = false -` - -func (q *Queries) GetRevisionById(ctx context.Context, id pgtype.UUID) (Revision, error) { - row := q.db.QueryRow(ctx, getRevisionById, id) - var i Revision - err := row.Scan( - &i.ID, - &i.ProjectID, - &i.VersionNo, - &i.SnapshotJson, - &i.SnapshotHash, - &i.ParentID, - &i.UserID, - &i.EditSummary, - &i.IsDeleted, - &i.CreatedAt, - ) - return i, err -} - -const getRevisionsByIDs = `-- name: GetRevisionsByIDs :many -SELECT id, project_id, version_no, snapshot_json, snapshot_hash, parent_id, user_id, edit_summary, is_deleted, created_at FROM revisions WHERE id = ANY($1::uuid[]) AND is_deleted = false -` - -func (q *Queries) GetRevisionsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Revision, error) { - rows, err := q.db.Query(ctx, getRevisionsByIDs, dollar_1) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Revision{} - for rows.Next() { - var i Revision - if err := rows.Scan( - &i.ID, - &i.ProjectID, - &i.VersionNo, - &i.SnapshotJson, - &i.SnapshotHash, - &i.ParentID, - &i.UserID, - &i.EditSummary, - &i.IsDeleted, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const searchRevisions = `-- name: SearchRevisions :many -SELECT id, project_id, version_no, snapshot_json, snapshot_hash, parent_id, user_id, edit_summary, is_deleted, created_at -FROM revisions -WHERE is_deleted = false - AND ($1::uuid IS NULL OR project_id = $1) - AND ($2::uuid IS NULL OR user_id = $2) - AND ($3::uuid IS NULL OR id < $3::uuid) -ORDER BY version_no DESC -LIMIT $4 -` - -type SearchRevisionsParams struct { - ProjectID pgtype.UUID `json:"project_id"` - UserID pgtype.UUID `json:"user_id"` - CursorID pgtype.UUID `json:"cursor_id"` - Limit int32 `json:"limit"` -} - -func (q *Queries) SearchRevisions(ctx context.Context, arg SearchRevisionsParams) ([]Revision, error) { - rows, err := q.db.Query(ctx, searchRevisions, - arg.ProjectID, - arg.UserID, - arg.CursorID, - arg.Limit, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Revision{} - for rows.Next() { - var i Revision - if err := rows.Scan( - &i.ID, - &i.ProjectID, - &i.VersionNo, - &i.SnapshotJson, - &i.SnapshotHash, - &i.ParentID, - &i.UserID, - &i.EditSummary, - &i.IsDeleted, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/gen/sqlc/submission.sql.go b/internal/gen/sqlc/submission.sql.go index b2a9280..9c0394f 100644 --- a/internal/gen/sqlc/submission.sql.go +++ b/internal/gen/sqlc/submission.sql.go @@ -16,26 +16,27 @@ SELECT count(*) FROM submissions s WHERE s.is_deleted = false AND ($1::uuid IS NULL OR s.project_id = $1) - AND ($2::uuid IS NULL OR s.submitted_by = $2) + AND ($2::uuid[] IS NULL OR uv.user_id = ANY($2::uuid[])) AND ($3::uuid IS NULL OR s.reviewed_by = $3) AND ( - $4::text[] IS NULL - OR s.status = ANY($4::text[]) + $4::smallint[] IS NULL + OR s.status = ANY($4::smallint[]) ) - AND ($5::timestamptz IS NULL OR s.submitted_at >= $5::timestamptz) - AND ($6::timestamptz IS NULL OR s.submitted_at <= $6::timestamptz) + AND ($5::timestamptz IS NULL OR s.created_at >= $5::timestamptz) + AND ($6::timestamptz IS NULL OR s.created_at <= $6::timestamptz) AND ( $7::text IS NULL OR s.id::text ILIKE '%' || $7::text || '%' OR - s.review_note ILIKE '%' || $7::text || '%' + s.review_note ILIKE '%' || $7::text || '%' OR + s.content ILIKE '%' || $7::text || '%' ) ` type CountSubmissionsParams struct { ProjectID pgtype.UUID `json:"project_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` + UserIds []pgtype.UUID `json:"user_ids"` ReviewedBy pgtype.UUID `json:"reviewed_by"` - Statuses []string `json:"statuses"` + Statuses []int16 `json:"statuses"` CreatedFrom pgtype.Timestamptz `json:"created_from"` CreatedTo pgtype.Timestamptz `json:"created_to"` SearchText pgtype.Text `json:"search_text"` @@ -44,7 +45,7 @@ type CountSubmissionsParams struct { func (q *Queries) CountSubmissions(ctx context.Context, arg CountSubmissionsParams) (int64, error) { row := q.db.QueryRow(ctx, countSubmissions, arg.ProjectID, - arg.SubmittedBy, + arg.UserIds, arg.ReviewedBy, arg.Statuses, arg.CreatedFrom, @@ -57,81 +58,85 @@ func (q *Queries) CountSubmissions(ctx context.Context, arg CountSubmissionsPara } const createSubmission = `-- name: CreateSubmission :one -WITH inserted_submission AS ( - INSERT INTO submissions ( - project_id, revision_id, submitted_by, status - ) VALUES ( - $1, $2, $3, $4 - ) - RETURNING id, project_id, revision_id, submitted_by, submitted_at, status, reviewed_by, reviewed_at, review_note, is_deleted +INSERT INTO submissions ( + project_id, commit_id, user_id, status, content +) VALUES ( + $1, $2, $3, $4, $5 ) -SELECT - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, - json_build_object( - 'id', u.id, - 'email', u.email, - 'display_name', up.display_name, - 'full_name', up.full_name, - 'avatar_url', up.avatar_url - )::json AS submitter, - CASE WHEN s.reviewed_by IS NOT NULL THEN - json_build_object( +RETURNING + id, project_id, commit_id, user_id, created_at, status, reviewed_by, reviewed_at, review_note, content, is_deleted, + ( + SELECT json_build_object( + 'id', u.id, + 'email', u.email, + 'display_name', up.display_name, + 'full_name', up.full_name, + 'avatar_url', up.avatar_url + ) + FROM users u + LEFT JOIN user_profiles up ON u.id = up.user_id + WHERE u.id = submissions.user_id + )::json AS user, + ( + SELECT json_build_object( 'id', ru.id, 'email', ru.email, 'display_name', rup.display_name, 'full_name', rup.full_name, 'avatar_url', rup.avatar_url - )::json - ELSE NULL::json END AS reviewer -FROM inserted_submission s -JOIN users u ON s.submitted_by = u.id -LEFT JOIN user_profiles up ON u.id = up.user_id -LEFT JOIN users ru ON s.reviewed_by = ru.id -LEFT JOIN user_profiles rup ON ru.id = rup.user_id + ) + FROM users ru + LEFT JOIN user_profiles rup ON ru.id = rup.user_id + WHERE ru.id = submissions.reviewed_by + )::json AS reviewer ` type CreateSubmissionParams struct { - ProjectID pgtype.UUID `json:"project_id"` - RevisionID pgtype.UUID `json:"revision_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` - Status int16 `json:"status"` + ProjectID pgtype.UUID `json:"project_id"` + CommitID pgtype.UUID `json:"commit_id"` + UserID pgtype.UUID `json:"user_id"` + Status int16 `json:"status"` + Content pgtype.Text `json:"content"` } type CreateSubmissionRow struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - RevisionID pgtype.UUID `json:"revision_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` - SubmittedAt pgtype.Timestamptz `json:"submitted_at"` - Status int16 `json:"status"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` - ReviewNote pgtype.Text `json:"review_note"` - IsDeleted bool `json:"is_deleted"` - Submitter []byte `json:"submitter"` - Reviewer []byte `json:"reviewer"` + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + CommitID pgtype.UUID `json:"commit_id"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Status int16 `json:"status"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + ReviewNote pgtype.Text `json:"review_note"` + Content pgtype.Text `json:"content"` + IsDeleted bool `json:"is_deleted"` + User []byte `json:"user"` + Reviewer []byte `json:"reviewer"` } func (q *Queries) CreateSubmission(ctx context.Context, arg CreateSubmissionParams) (CreateSubmissionRow, error) { row := q.db.QueryRow(ctx, createSubmission, arg.ProjectID, - arg.RevisionID, - arg.SubmittedBy, + arg.CommitID, + arg.UserID, arg.Status, + arg.Content, ) var i CreateSubmissionRow err := row.Scan( &i.ID, &i.ProjectID, - &i.RevisionID, - &i.SubmittedBy, - &i.SubmittedAt, + &i.CommitID, + &i.UserID, + &i.CreatedAt, &i.Status, &i.ReviewedBy, &i.ReviewedAt, &i.ReviewNote, + &i.Content, &i.IsDeleted, - &i.Submitter, + &i.User, &i.Reviewer, ) return i, err @@ -150,14 +155,14 @@ func (q *Queries) DeleteSubmission(ctx context.Context, id pgtype.UUID) error { const getSubmissionById = `-- name: GetSubmissionById :one SELECT - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, + s.id, s.project_id, s.commit_id, s.user_id, s.created_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.content, s.is_deleted, json_build_object( 'id', u.id, 'email', u.email, 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS submitter, + )::json AS user, CASE WHEN s.reviewed_by IS NOT NULL THEN json_build_object( 'id', ru.id, @@ -168,7 +173,7 @@ SELECT )::json ELSE NULL::json END AS reviewer FROM submissions s -JOIN users u ON s.submitted_by = u.id +JOIN users u ON s.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id LEFT JOIN users ru ON s.reviewed_by = ru.id LEFT JOIN user_profiles rup ON ru.id = rup.user_id @@ -176,18 +181,19 @@ WHERE s.id = $1 AND s.is_deleted = false ` type GetSubmissionByIdRow struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - RevisionID pgtype.UUID `json:"revision_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` - SubmittedAt pgtype.Timestamptz `json:"submitted_at"` - Status int16 `json:"status"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` - ReviewNote pgtype.Text `json:"review_note"` - IsDeleted bool `json:"is_deleted"` - Submitter []byte `json:"submitter"` - Reviewer []byte `json:"reviewer"` + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + CommitID pgtype.UUID `json:"commit_id"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Status int16 `json:"status"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + ReviewNote pgtype.Text `json:"review_note"` + Content pgtype.Text `json:"content"` + IsDeleted bool `json:"is_deleted"` + User []byte `json:"user"` + Reviewer []byte `json:"reviewer"` } func (q *Queries) GetSubmissionById(ctx context.Context, id pgtype.UUID) (GetSubmissionByIdRow, error) { @@ -196,15 +202,16 @@ func (q *Queries) GetSubmissionById(ctx context.Context, id pgtype.UUID) (GetSub err := row.Scan( &i.ID, &i.ProjectID, - &i.RevisionID, - &i.SubmittedBy, - &i.SubmittedAt, + &i.CommitID, + &i.UserID, + &i.CreatedAt, &i.Status, &i.ReviewedBy, &i.ReviewedAt, &i.ReviewNote, + &i.Content, &i.IsDeleted, - &i.Submitter, + &i.User, &i.Reviewer, ) return i, err @@ -212,14 +219,14 @@ func (q *Queries) GetSubmissionById(ctx context.Context, id pgtype.UUID) (GetSub const getSubmissionsByIDs = `-- name: GetSubmissionsByIDs :many SELECT - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, + s.id, s.project_id, s.commit_id, s.user_id, s.created_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.content, s.is_deleted, json_build_object( 'id', u.id, 'email', u.email, 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS submitter, + )::json AS user, CASE WHEN s.reviewed_by IS NOT NULL THEN json_build_object( 'id', ru.id, @@ -230,7 +237,7 @@ SELECT )::json ELSE NULL::json END AS reviewer FROM submissions s -JOIN users u ON s.submitted_by = u.id +JOIN users u ON s.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id LEFT JOIN users ru ON s.reviewed_by = ru.id LEFT JOIN user_profiles rup ON ru.id = rup.user_id @@ -238,18 +245,19 @@ WHERE s.id = ANY($1::uuid[]) AND s.is_deleted = false ` type GetSubmissionsByIDsRow struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - RevisionID pgtype.UUID `json:"revision_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` - SubmittedAt pgtype.Timestamptz `json:"submitted_at"` - Status int16 `json:"status"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` - ReviewNote pgtype.Text `json:"review_note"` - IsDeleted bool `json:"is_deleted"` - Submitter []byte `json:"submitter"` - Reviewer []byte `json:"reviewer"` + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + CommitID pgtype.UUID `json:"commit_id"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Status int16 `json:"status"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + ReviewNote pgtype.Text `json:"review_note"` + Content pgtype.Text `json:"content"` + IsDeleted bool `json:"is_deleted"` + User []byte `json:"user"` + Reviewer []byte `json:"reviewer"` } func (q *Queries) GetSubmissionsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]GetSubmissionsByIDsRow, error) { @@ -264,15 +272,16 @@ func (q *Queries) GetSubmissionsByIDs(ctx context.Context, dollar_1 []pgtype.UUI if err := rows.Scan( &i.ID, &i.ProjectID, - &i.RevisionID, - &i.SubmittedBy, - &i.SubmittedAt, + &i.CommitID, + &i.UserID, + &i.CreatedAt, &i.Status, &i.ReviewedBy, &i.ReviewedAt, &i.ReviewNote, + &i.Content, &i.IsDeleted, - &i.Submitter, + &i.User, &i.Reviewer, ); err != nil { return nil, err @@ -287,14 +296,14 @@ func (q *Queries) GetSubmissionsByIDs(ctx context.Context, dollar_1 []pgtype.UUI const searchSubmissions = `-- name: SearchSubmissions :many SELECT - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, + s.id, s.project_id, s.commit_id, s.user_id, s.created_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.content, s.is_deleted, json_build_object( 'id', u.id, 'email', u.email, 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS submitter, + )::json AS user, CASE WHEN s.reviewed_by IS NOT NULL THEN json_build_object( 'id', ru.id, @@ -305,42 +314,43 @@ SELECT )::json ELSE NULL::json END AS reviewer FROM submissions s -JOIN users u ON s.submitted_by = u.id +JOIN users u ON s.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id LEFT JOIN users ru ON s.reviewed_by = ru.id LEFT JOIN user_profiles rup ON ru.id = rup.user_id WHERE s.is_deleted = false AND ($1::uuid IS NULL OR s.project_id = $1) - AND ($2::uuid IS NULL OR s.submitted_by = $2) + AND ($2::uuid[] IS NULL OR uv.user_id = ANY($2::uuid[])) AND ($3::uuid IS NULL OR s.reviewed_by = $3) AND ( - $4::text[] IS NULL - OR s.status = ANY($4::text[]) + $4::smallint[] IS NULL + OR s.status = ANY($4::smallint[]) ) - AND ($5::timestamptz IS NULL OR s.submitted_at >= $5::timestamptz) - AND ($6::timestamptz IS NULL OR s.submitted_at <= $6::timestamptz) + AND ($5::timestamptz IS NULL OR s.created_at >= $5::timestamptz) + AND ($6::timestamptz IS NULL OR s.created_at <= $6::timestamptz) AND ( $7::text IS NULL OR s.id::text ILIKE '%' || $7::text || '%' OR - s.review_note ILIKE '%' || $7::text || '%' + s.review_note ILIKE '%' || $7::text || '%' OR + s.content ILIKE '%' || $7::text || '%' ) ORDER BY - CASE WHEN $8 = 'submitted_at' AND $9 = 'asc' THEN s.submitted_at END ASC, - CASE WHEN $8 = 'submitted_at' AND $9 = 'desc' THEN s.submitted_at END DESC, + CASE WHEN $8 = 'created_at' AND $9 = 'asc' THEN s.created_at END ASC, + CASE WHEN $8 = 'created_at' AND $9 = 'desc' THEN s.created_at END DESC, CASE WHEN $8 = 'reviewed_at' AND $9 = 'asc' THEN s.reviewed_at END ASC, CASE WHEN $8 = 'reviewed_at' AND $9 = 'desc' THEN s.reviewed_at END DESC, CASE WHEN $8 = 'status' AND $9 = 'asc' THEN s.status END ASC, CASE WHEN $8 = 'status' AND $9 = 'desc' THEN s.status END DESC, - CASE WHEN $8 IS NULL THEN s.submitted_at END DESC + CASE WHEN $8 IS NULL THEN s.created_at END DESC LIMIT $11 OFFSET $10 ` type SearchSubmissionsParams struct { ProjectID pgtype.UUID `json:"project_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` + UserIds []pgtype.UUID `json:"user_ids"` ReviewedBy pgtype.UUID `json:"reviewed_by"` - Statuses []string `json:"statuses"` + Statuses []int16 `json:"statuses"` CreatedFrom pgtype.Timestamptz `json:"created_from"` CreatedTo pgtype.Timestamptz `json:"created_to"` SearchText pgtype.Text `json:"search_text"` @@ -351,24 +361,25 @@ type SearchSubmissionsParams struct { } type SearchSubmissionsRow struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - RevisionID pgtype.UUID `json:"revision_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` - SubmittedAt pgtype.Timestamptz `json:"submitted_at"` - Status int16 `json:"status"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` - ReviewNote pgtype.Text `json:"review_note"` - IsDeleted bool `json:"is_deleted"` - Submitter []byte `json:"submitter"` - Reviewer []byte `json:"reviewer"` + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + CommitID pgtype.UUID `json:"commit_id"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Status int16 `json:"status"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + ReviewNote pgtype.Text `json:"review_note"` + Content pgtype.Text `json:"content"` + IsDeleted bool `json:"is_deleted"` + User []byte `json:"user"` + Reviewer []byte `json:"reviewer"` } func (q *Queries) SearchSubmissions(ctx context.Context, arg SearchSubmissionsParams) ([]SearchSubmissionsRow, error) { rows, err := q.db.Query(ctx, searchSubmissions, arg.ProjectID, - arg.SubmittedBy, + arg.UserIds, arg.ReviewedBy, arg.Statuses, arg.CreatedFrom, @@ -389,15 +400,16 @@ func (q *Queries) SearchSubmissions(ctx context.Context, arg SearchSubmissionsPa if err := rows.Scan( &i.ID, &i.ProjectID, - &i.RevisionID, - &i.SubmittedBy, - &i.SubmittedAt, + &i.CommitID, + &i.UserID, + &i.CreatedAt, &i.Status, &i.ReviewedBy, &i.ReviewedAt, &i.ReviewNote, + &i.Content, &i.IsDeleted, - &i.Submitter, + &i.User, &i.Reviewer, ); err != nil { return nil, err @@ -415,23 +427,23 @@ UPDATE submissions SET status = COALESCE($1, status), reviewed_by = COALESCE($2, reviewed_by), - reviewed_at = COALESCE($3, reviewed_at), - review_note = COALESCE($4, review_note) + review_note = COALESCE($3, review_note), + content = COALESCE($4, content) FROM submissions s -JOIN users u ON s.submitted_by = u.id +JOIN users u ON s.user_id = u.id LEFT JOIN user_profiles up ON u.id = up.user_id LEFT JOIN users ru ON s.reviewed_by = ru.id LEFT JOIN user_profiles rup ON ru.id = rup.user_id WHERE s.id = $5 RETURNING - s.id, s.project_id, s.revision_id, s.submitted_by, s.submitted_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.is_deleted, + s.id, s.project_id, s.commit_id, s.user_id, s.created_at, s.status, s.reviewed_by, s.reviewed_at, s.review_note, s.content, s.is_deleted, json_build_object( 'id', u.id, 'email', u.email, 'display_name', up.display_name, 'full_name', up.full_name, 'avatar_url', up.avatar_url - )::json AS submitter, + )::json AS user, CASE WHEN s.reviewed_by IS NOT NULL THEN json_build_object( 'id', ru.id, @@ -444,49 +456,51 @@ RETURNING ` type UpdateSubmissionParams struct { - Status pgtype.Int2 `json:"status"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` - ReviewNote pgtype.Text `json:"review_note"` - ID pgtype.UUID `json:"id"` + Status pgtype.Int2 `json:"status"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewNote pgtype.Text `json:"review_note"` + Content pgtype.Text `json:"content"` + ID pgtype.UUID `json:"id"` } type UpdateSubmissionRow struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - RevisionID pgtype.UUID `json:"revision_id"` - SubmittedBy pgtype.UUID `json:"submitted_by"` - SubmittedAt pgtype.Timestamptz `json:"submitted_at"` - Status int16 `json:"status"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` - ReviewNote pgtype.Text `json:"review_note"` - IsDeleted bool `json:"is_deleted"` - Submitter []byte `json:"submitter"` - Reviewer []byte `json:"reviewer"` + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + CommitID pgtype.UUID `json:"commit_id"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Status int16 `json:"status"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + ReviewNote pgtype.Text `json:"review_note"` + Content pgtype.Text `json:"content"` + IsDeleted bool `json:"is_deleted"` + User []byte `json:"user"` + Reviewer []byte `json:"reviewer"` } func (q *Queries) UpdateSubmission(ctx context.Context, arg UpdateSubmissionParams) (UpdateSubmissionRow, error) { row := q.db.QueryRow(ctx, updateSubmission, arg.Status, arg.ReviewedBy, - arg.ReviewedAt, arg.ReviewNote, + arg.Content, arg.ID, ) var i UpdateSubmissionRow err := row.Scan( &i.ID, &i.ProjectID, - &i.RevisionID, - &i.SubmittedBy, - &i.SubmittedAt, + &i.CommitID, + &i.UserID, + &i.CreatedAt, &i.Status, &i.ReviewedBy, &i.ReviewedAt, &i.ReviewNote, + &i.Content, &i.IsDeleted, - &i.Submitter, + &i.User, &i.Reviewer, ) return i, err diff --git a/internal/models/commit.go b/internal/models/commit.go new file mode 100644 index 0000000..e61f477 --- /dev/null +++ b/internal/models/commit.go @@ -0,0 +1,48 @@ +package models + +import ( + "encoding/json" + "history-api/internal/dtos/response" + "time" +) + +type CommitEntity struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + SnapshotJson json.RawMessage `json:"snapshot_json"` + SnapshotHash string `json:"snapshot_hash"` + UserID string `json:"user_id"` + EditSummary string `json:"edit_summary"` + IsDeleted bool `json:"is_deleted"` + CreatedAt *time.Time `json:"created_at"` + User *UserSimpleEntity `json:"user"` +} + +func (c *CommitEntity) ToResponse() *response.CommitResponse { + if c == nil { + return nil + } + var userRes *response.UserSimpleResponse + if c.User != nil { + userRes = c.User.ToResponse() + } + + return &response.CommitResponse{ + ID: c.ID, + ProjectID: c.ProjectID, + SnapshotJson: c.SnapshotJson, + SnapshotHash: c.SnapshotHash, + UserID: c.UserID, + EditSummary: c.EditSummary, + CreatedAt: c.CreatedAt, + User: userRes, + } +} + +func CommitsEntityToResponse(entities []*CommitEntity) []*response.CommitResponse { + res := make([]*response.CommitResponse, 0, len(entities)) + for _, e := range entities { + res = append(res, e.ToResponse()) + } + return res +} diff --git a/internal/models/project.go b/internal/models/project.go index a355f58..43c78fd 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -7,21 +7,33 @@ import ( "time" ) +type CommitSimple struct { + ID string `json:"id"` + EditSummary string `json:"edit_summary"` +} + +type MemberSimple struct { + UserID string `json:"user_id"` + Role constants.ProjectMemberRole `json:"role"` + DisplayName string `json:"display_name"` + AvatarUrl string `json:"avatar_url"` +} + type ProjectEntity struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - LatestRevisionID *string `json:"latest_revision_id"` - VersionCount int32 `json:"version_count"` - ProjectStatus constants.ProjectStatusType `json:"project_status"` - LockedBy *string `json:"locked_by"` - IsDeleted bool `json:"is_deleted"` - UserID string `json:"user_id"` - CreatedAt *time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at"` - User *UserSimpleEntity `json:"user"` - CommitIds []string `json:"commit_ids"` - SubmissionIds []string `json:"submission_ids"` + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + LatestCommitID *string `json:"latest_commit_id"` + ProjectStatus constants.ProjectStatusType `json:"project_status"` + LockedBy *string `json:"locked_by"` + IsDeleted bool `json:"is_deleted"` + UserID string `json:"user_id"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + User *UserSimpleEntity `json:"user"` + Commits []CommitSimple `json:"commits"` + SubmissionIds []string `json:"submission_ids"` + Members []MemberSimple `json:"members"` } func (p *ProjectEntity) ParseUser(data []byte) error { @@ -32,6 +44,22 @@ func (p *ProjectEntity) ParseUser(data []byte) error { return json.Unmarshal(data, &p.User) } +func (p *ProjectEntity) ParseCommits(data []byte) error { + if len(data) == 0 || string(data) == "null" || string(data) == "[]" { + p.Commits = []CommitSimple{} + return nil + } + return json.Unmarshal(data, &p.Commits) +} + +func (p *ProjectEntity) ParseMembers(data []byte) error { + if len(data) == 0 || string(data) == "null" || string(data) == "[]" { + p.Members = []MemberSimple{} + return nil + } + return json.Unmarshal(data, &p.Members) +} + func (p *ProjectEntity) ToResponse() *response.ProjectResponse { if p == nil { return nil @@ -41,21 +69,39 @@ func (p *ProjectEntity) ToResponse() *response.ProjectResponse { userResponse = p.User.ToResponse() } + commits := make([]response.CommitSimpleResponse, 0, len(p.Commits)) + for _, c := range p.Commits { + commits = append(commits, response.CommitSimpleResponse{ + ID: c.ID, + EditSummary: c.EditSummary, + }) + } + + members := make([]response.MemberSimpleResponse, 0, len(p.Members)) + for _, m := range p.Members { + members = append(members, response.MemberSimpleResponse{ + UserID: m.UserID, + Role: m.Role.String(), + DisplayName: m.DisplayName, + AvatarUrl: m.AvatarUrl, + }) + } + return &response.ProjectResponse{ - ID: p.ID, - Title: p.Title, - Description: p.Description, - LatestRevisionID: p.LatestRevisionID, - VersionCount: p.VersionCount, - ProjectStatus: p.ProjectStatus.String(), - LockedBy: p.LockedBy, - IsDeleted: p.IsDeleted, - UserID: p.UserID, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - User: userResponse, - CommitIds: p.CommitIds, - SubmissionIds: p.SubmissionIds, + ID: p.ID, + Title: p.Title, + Description: p.Description, + LatestCommitID: p.LatestCommitID, + ProjectStatus: p.ProjectStatus.String(), + LockedBy: p.LockedBy, + IsDeleted: p.IsDeleted, + UserID: p.UserID, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + User: userResponse, + Commits: commits, + SubmissionIds: p.SubmissionIds, + Members: members, } } diff --git a/internal/models/submission.go b/internal/models/submission.go new file mode 100644 index 0000000..5029669 --- /dev/null +++ b/internal/models/submission.go @@ -0,0 +1,75 @@ +package models + +import ( + "encoding/json" + "history-api/internal/dtos/response" + "history-api/pkg/constants" + "time" +) + +type SubmissionEntity struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + CommitID string `json:"commit_id"` + UserID string `json:"user_id"` + CreatedAt *time.Time `json:"created_at"` + Status constants.StatusType `json:"status"` + ReviewedBy *string `json:"reviewed_by"` + ReviewedAt *time.Time `json:"reviewed_at"` + ReviewNote *string `json:"review_note"` + Content *string `json:"content"` + IsDeleted bool `json:"is_deleted"` + User *UserSimpleEntity `json:"user"` + Reviewer *UserSimpleEntity `json:"reviewer"` +} + +func (s *SubmissionEntity) ParseUser(data []byte) error { + if len(data) == 0 || string(data) == "null" { + s.User = nil + return nil + } + return json.Unmarshal(data, &s.User) +} + +func (s *SubmissionEntity) ParseReviewer(data []byte) error { + if len(data) == 0 || string(data) == "null" { + s.Reviewer = nil + return nil + } + return json.Unmarshal(data, &s.Reviewer) +} + +func (s *SubmissionEntity) ToResponse() *response.SubmissionResponse { + if s == nil { + return nil + } + + return &response.SubmissionResponse{ + ID: s.ID, + ProjectID: s.ProjectID, + CommitID: s.CommitID, + UserID: s.UserID, + CreatedAt: s.CreatedAt, + Status: s.Status.String(), + ReviewedBy: s.ReviewedBy, + ReviewedAt: s.ReviewedAt, + ReviewNote: s.ReviewNote, + Content: s.Content, + User: s.User.ToResponse(), + Reviewer: s.Reviewer.ToResponse(), + } +} + +func SubmissionsEntityToResponse(submissions []*SubmissionEntity) []*response.SubmissionResponse { + out := make([]*response.SubmissionResponse, 0) + if submissions == nil { + return out + } + for _, submission := range submissions { + if submission == nil { + continue + } + out = append(out, submission.ToResponse()) + } + return out +} diff --git a/internal/repositories/commitRepository.go b/internal/repositories/commitRepository.go new file mode 100644 index 0000000..dc2fa22 --- /dev/null +++ b/internal/repositories/commitRepository.go @@ -0,0 +1,230 @@ +package repositories + +import ( + "context" + "crypto/md5" + "encoding/json" + "fmt" + "history-api/internal/gen/sqlc" + "history-api/internal/models" + "history-api/pkg/cache" + "history-api/pkg/constants" + "history-api/pkg/convert" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type CommitRepository interface { + Create(ctx context.Context, params sqlc.CreateCommitParams) (*models.CommitEntity, error) + GetByID(ctx context.Context, id pgtype.UUID) (*models.CommitEntity, error) + GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.CommitEntity, error) + Search(ctx context.Context, params sqlc.SearchCommitsParams) ([]*models.CommitEntity, error) + WithTx(tx pgx.Tx) CommitRepository +} + +type commitRepository struct { + q *sqlc.Queries + c cache.Cache +} + +func NewCommitRepository(db sqlc.DBTX, c cache.Cache) CommitRepository { + return &commitRepository{ + q: sqlc.New(db), + c: c, + } +} + +func (r *commitRepository) WithTx(tx pgx.Tx) CommitRepository { + return &commitRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + +func (r *commitRepository) Create(ctx context.Context, params sqlc.CreateCommitParams) (*models.CommitEntity, error) { + row, err := r.q.CreateCommit(ctx, params) + if err != nil { + return nil, err + } + + return &models.CommitEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + SnapshotJson: row.SnapshotJson, + SnapshotHash: convert.TextToString(row.SnapshotHash), + UserID: convert.UUIDToString(row.UserID), + EditSummary: convert.TextToString(row.EditSummary), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + }, nil +} + +func (r *commitRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.CommitEntity, error) { + cacheId := fmt.Sprintf("commit:id:%s", convert.UUIDToString(id)) + var commit models.CommitEntity + err := r.c.Get(ctx, cacheId, &commit) + if err == nil { + _ = r.c.Set(ctx, cacheId, commit, constants.NormalCacheDuration) + return &commit, nil + } + + row, err := r.q.GetCommitById(ctx, id) + if err != nil { + return nil, err + } + + commit = models.CommitEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + SnapshotJson: row.SnapshotJson, + SnapshotHash: convert.TextToString(row.SnapshotHash), + UserID: convert.UUIDToString(row.UserID), + EditSummary: convert.TextToString(row.EditSummary), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + + _ = r.c.Set(ctx, cacheId, commit, constants.NormalCacheDuration) + + return &commit, nil +} + +func (r *commitRepository) generateQueryKey(prefix string, params any) string { + b, _ := json.Marshal(params) + hash := fmt.Sprintf("%x", md5.Sum(b)) + return fmt.Sprintf("%s:query:%s", prefix, hash) +} + +func (r *commitRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.CommitEntity, error) { + if len(ids) == 0 { + return []*models.CommitEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("commit:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var commits []*models.CommitEntity + missingToCache := make(map[string]any) + + var missingPgIds []pgtype.UUID + for i, b := range raws { + if len(b) == 0 { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err == nil { + missingPgIds = append(missingPgIds, pgId) + } + } + } + + dbMap := make(map[string]*models.CommitEntity) + if len(missingPgIds) > 0 { + dbRows, err := r.q.GetCommitsByIDs(ctx, missingPgIds) + if err == nil { + for _, row := range dbRows { + item := models.CommitEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + SnapshotJson: row.SnapshotJson, + SnapshotHash: convert.TextToString(row.SnapshotHash), + UserID: convert.UUIDToString(row.UserID), + EditSummary: convert.TextToString(row.EditSummary), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + dbMap[item.ID] = &item + } + } + } + + for i, b := range raws { + if len(b) > 0 { + var c models.CommitEntity + if err := json.Unmarshal(b, &c); err == nil { + commits = append(commits, &c) + } + } else { + if item, ok := dbMap[ids[i]]; ok { + commits = append(commits, item) + missingToCache[keys[i]] = item + } + } + } + + if len(missingToCache) > 0 { + _ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration) + } + + return commits, nil +} + +func (r *commitRepository) GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.CommitEntity, error) { + queryKey := fmt.Sprintf("commit:project:%s", convert.UUIDToString(projectID)) + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.GetCommitsByProjectID(ctx, projectID) + if err != nil { + return nil, err + } + + entities := make([]*models.CommitEntity, 0, len(rows)) + for _, row := range rows { + entities = append(entities, &models.CommitEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + SnapshotJson: row.SnapshotJson, + SnapshotHash: convert.TextToString(row.SnapshotHash), + UserID: convert.UUIDToString(row.UserID), + EditSummary: convert.TextToString(row.EditSummary), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + }) + } + return entities, nil +} + +func (r *commitRepository) Search(ctx context.Context, params sqlc.SearchCommitsParams) ([]*models.CommitEntity, error) { + queryKey := r.generateQueryKey("commit:search", params) + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + rows, err := r.q.SearchCommits(ctx, params) + if err != nil { + return nil, err + } + var commits []*models.CommitEntity + var ids []string + commitToCache := make(map[string]any) + + for _, row := range rows { + commit := &models.CommitEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + SnapshotJson: row.SnapshotJson, + SnapshotHash: convert.TextToString(row.SnapshotHash), + UserID: convert.UUIDToString(row.UserID), + EditSummary: convert.TextToString(row.EditSummary), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + ids = append(ids, commit.ID) + commits = append(commits, commit) + commitToCache[fmt.Sprintf("commit:id:%s", commit.ID)] = commit + } + + if len(commitToCache) > 0 { + _ = r.c.MSet(ctx, commitToCache, constants.NormalCacheDuration) + } + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return commits, nil +} diff --git a/internal/repositories/entityRepository.go b/internal/repositories/entityRepository.go index 5df83b3..560fecd 100644 --- a/internal/repositories/entityRepository.go +++ b/internal/repositories/entityRepository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" @@ -22,6 +23,7 @@ type EntityRepository interface { Create(ctx context.Context, params sqlc.CreateEntityParams) (*models.EntityEntity, error) Update(ctx context.Context, params sqlc.UpdateEntityParams) (*models.EntityEntity, error) Delete(ctx context.Context, id pgtype.UUID) error + WithTx(tx pgx.Tx) EntityRepository } type entityRepository struct { @@ -36,6 +38,13 @@ func NewEntityRepository(db sqlc.DBTX, c cache.Cache) EntityRepository { } } +func (r *entityRepository) WithTx(tx pgx.Tx) EntityRepository { + return &entityRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + func (r *entityRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) @@ -193,8 +202,6 @@ func (r *entityRepository) Create(ctx context.Context, params sqlc.CreateEntityP CreatedAt: convert.TimeToPtr(row.CreatedAt), UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, fmt.Sprintf("entity:id:%s", entity.ID), entity, constants.NormalCacheDuration) - go func() { _ = r.c.DelByPattern(context.Background(), "entity:search*") }() @@ -215,7 +222,7 @@ func (r *entityRepository) Update(ctx context.Context, params sqlc.UpdateEntityP CreatedAt: convert.TimeToPtr(row.CreatedAt), UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, fmt.Sprintf("entity:id:%s", entity.ID), entity, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("entity:id:%s", entity.ID)) return &entity, nil } diff --git a/internal/repositories/geometryRepository.go b/internal/repositories/geometryRepository.go index 335b44a..cabdf07 100644 --- a/internal/repositories/geometryRepository.go +++ b/internal/repositories/geometryRepository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/dtos/response" @@ -25,6 +26,7 @@ type GeometryRepository interface { Delete(ctx context.Context, id pgtype.UUID) error CreateEntityGeometries(ctx context.Context, params sqlc.CreateEntityGeometriesParams) error BulkDeleteEntityGeometriesByEntityId(ctx context.Context, entityId pgtype.UUID) error + WithTx(tx pgx.Tx) GeometryRepository } type geometryRepository struct { @@ -39,6 +41,13 @@ func NewGeometryRepository(db sqlc.DBTX, c cache.Cache) GeometryRepository { } } +func (r *geometryRepository) WithTx(tx pgx.Tx) GeometryRepository { + return &geometryRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + func (r *geometryRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) @@ -87,9 +96,9 @@ func (r *geometryRepository) getByIDsWithFallback(ctx context.Context, ids []str MaxLng: row.MaxLng, MaxLat: row.MaxLat, }, - IsDeleted: row.IsDeleted, - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } dbMap[item.ID] = &item } @@ -148,9 +157,9 @@ func (r *geometryRepository) GetByID(ctx context.Context, id pgtype.UUID) (*mode MaxLng: row.MaxLng, MaxLat: row.MaxLat, }, - IsDeleted: row.IsDeleted, - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } _ = r.c.Set(ctx, cacheId, geometry, constants.NormalCacheDuration) @@ -186,9 +195,9 @@ func (r *geometryRepository) Search(ctx context.Context, params sqlc.SearchGeome MaxLng: row.MaxLng, MaxLat: row.MaxLat, }, - IsDeleted: row.IsDeleted, - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } ids = append(ids, geometry.ID) geometries = append(geometries, geometry) @@ -224,16 +233,15 @@ func (r *geometryRepository) Create(ctx context.Context, params sqlc.CreateGeome MaxLng: row.MaxLng, MaxLat: row.MaxLat, }, - IsDeleted: row.IsDeleted, - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, fmt.Sprintf("geometry:id:%s", geometry.ID), geometry, constants.NormalCacheDuration) go func() { - bgCtx := context.Background() - _ = r.c.DelByPattern(bgCtx, "geometry:search*") + _ = r.c.DelByPattern(context.Background(), "geometry:search*") }() + return &geometry, nil } @@ -255,11 +263,11 @@ func (r *geometryRepository) Update(ctx context.Context, params sqlc.UpdateGeome MaxLng: row.MaxLng, MaxLat: row.MaxLat, }, - IsDeleted: row.IsDeleted, - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, fmt.Sprintf("geometry:id:%s", geometry.ID), geometry, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("geometry:id:%s", geometry.ID)) return &geometry, nil } diff --git a/internal/repositories/mediaRepository.go b/internal/repositories/mediaRepository.go index d47d755..971fd64 100644 --- a/internal/repositories/mediaRepository.go +++ b/internal/repositories/mediaRepository.go @@ -11,6 +11,7 @@ import ( "history-api/pkg/constants" "history-api/pkg/convert" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -23,6 +24,7 @@ type MediaRepository interface { Delete(ctx context.Context, id pgtype.UUID) error BulkDelete(ctx context.Context, ids []pgtype.UUID) error Create(ctx context.Context, params sqlc.CreateMediaParams) (*models.MediaEntity, error) + WithTx(tx pgx.Tx) MediaRepository } type mediaRepository struct { @@ -37,6 +39,13 @@ func NewMediaRepository(db sqlc.DBTX, c cache.Cache) MediaRepository { } } +func (r *mediaRepository) WithTx(tx pgx.Tx) MediaRepository { + return &mediaRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + func (r *mediaRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) @@ -167,8 +176,7 @@ func (r *mediaRepository) Create(ctx context.Context, params sqlc.CreateMediaPar CreatedAt: convert.TimeToPtr(row.CreatedAt), UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, fmt.Sprintf("media:id:%s", media.ID), media, constants.NormalCacheDuration) - _ = r.c.Del(ctx, fmt.Sprintf("media:userId:%s", convert.UUIDToString(params.UserID))) + return &media, nil } @@ -181,8 +189,7 @@ func (r *mediaRepository) Delete(ctx context.Context, id pgtype.UUID) error { cacheId := fmt.Sprintf("media:id:%s", convert.UUIDToString(id)) _ = r.c.Del(ctx, cacheId) go func() { - bgCtx := context.Background() - _ = r.c.DelByPattern(bgCtx, "media:count*") + _ = r.c.DelByPattern(context.Background(), "media:count*") }() return nil } diff --git a/internal/repositories/projectRepository.go b/internal/repositories/projectRepository.go index 5193db5..0c77973 100644 --- a/internal/repositories/projectRepository.go +++ b/internal/repositories/projectRepository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" @@ -24,6 +25,13 @@ type ProjectRepository interface { Create(ctx context.Context, params sqlc.CreateProjectParams) (*models.ProjectEntity, error) Update(ctx context.Context, params sqlc.UpdateProjectParams) (*models.ProjectEntity, error) Delete(ctx context.Context, id pgtype.UUID) error + AddMember(ctx context.Context, params sqlc.AddProjectMemberParams) error + UpdateMemberRole(ctx context.Context, params sqlc.UpdateProjectMemberRoleParams) error + RemoveMember(ctx context.Context, params sqlc.RemoveProjectMemberParams) error + CheckPermission(ctx context.Context, params sqlc.CheckProjectPermissionParams) (int16, error) + ChangeOwner(ctx context.Context, params sqlc.ChangeProjectOwnerParams) error + UpdateLatestCommit(ctx context.Context, params sqlc.UpdateLatestCommitParams) error + WithTx(tx pgx.Tx) ProjectRepository } type projectRepository struct { @@ -38,13 +46,18 @@ func NewProjectRepository(db sqlc.DBTX, c cache.Cache) ProjectRepository { } } +func (r *projectRepository) WithTx(tx pgx.Tx) ProjectRepository { + return &projectRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} func (r *projectRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) return fmt.Sprintf("%s:%s", prefix, hash) } - func (r *projectRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.ProjectEntity, error) { if len(ids) == 0 { return []*models.ProjectEntity{}, nil @@ -75,21 +88,21 @@ func (r *projectRepository) getByIDsWithFallback(ctx context.Context, ids []stri if err == nil { for _, row := range dbRows { item := models.ProjectEntity{ - ID: convert.UUIDToString(row.ID), - Title: row.Title, - Description: convert.TextToString(row.Description), - LatestRevisionID: convert.UUIDToStringPtr(row.LatestRevisionID), - VersionCount: row.VersionCount, - ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), - LockedBy: convert.UUIDToStringPtr(row.LockedBy), - IsDeleted: row.IsDeleted, - UserID: convert.UUIDToString(row.UserID), - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), - CommitIds: convert.ListUUIDToString(row.CommitIds), - SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), + ID: convert.UUIDToString(row.ID), + Title: row.Title, + Description: convert.TextToString(row.Description), + LatestCommitID: convert.UUIDToStringPtr(row.LatestCommitID), + ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), + LockedBy: convert.UUIDToStringPtr(row.LockedBy), + IsDeleted: row.IsDeleted, + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), } _ = item.ParseUser(row.User) + _ = item.ParseCommits(row.Commits) + _ = item.ParseMembers(row.Members) dbMap[item.ID] = &item } } @@ -135,21 +148,21 @@ func (r *projectRepository) GetByID(ctx context.Context, id pgtype.UUID) (*model } project = models.ProjectEntity{ - ID: convert.UUIDToString(row.ID), - Title: row.Title, - Description: convert.TextToString(row.Description), - LatestRevisionID: convert.UUIDToStringPtr(row.LatestRevisionID), - VersionCount: row.VersionCount, - ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), - LockedBy: convert.UUIDToStringPtr(row.LockedBy), - IsDeleted: row.IsDeleted, - UserID: convert.UUIDToString(row.UserID), - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), - CommitIds: convert.ListUUIDToString(row.CommitIds), - SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), + ID: convert.UUIDToString(row.ID), + Title: row.Title, + Description: convert.TextToString(row.Description), + LatestCommitID: convert.UUIDToStringPtr(row.LatestCommitID), + ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), + LockedBy: convert.UUIDToStringPtr(row.LockedBy), + IsDeleted: row.IsDeleted, + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), } _ = project.ParseUser(row.User) + _ = project.ParseCommits(row.Commits) + _ = project.ParseMembers(row.Members) _ = r.c.Set(ctx, cacheId, project, constants.NormalCacheDuration) @@ -167,28 +180,28 @@ func (r *projectRepository) GetByUserID(ctx context.Context, params sqlc.GetProj if err != nil { return nil, err } - + var projects []*models.ProjectEntity var ids []string projectToCache := make(map[string]any) for _, row := range rows { project := &models.ProjectEntity{ - ID: convert.UUIDToString(row.ID), - Title: row.Title, - Description: convert.TextToString(row.Description), - LatestRevisionID: convert.UUIDToStringPtr(row.LatestRevisionID), - VersionCount: row.VersionCount, - ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), - LockedBy: convert.UUIDToStringPtr(row.LockedBy), - IsDeleted: row.IsDeleted, - UserID: convert.UUIDToString(row.UserID), - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), - CommitIds: convert.ListUUIDToString(row.CommitIds), - SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), + ID: convert.UUIDToString(row.ID), + Title: row.Title, + Description: convert.TextToString(row.Description), + LatestCommitID: convert.UUIDToStringPtr(row.LatestCommitID), + ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), + LockedBy: convert.UUIDToStringPtr(row.LockedBy), + IsDeleted: row.IsDeleted, + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), } _ = project.ParseUser(row.User) + _ = project.ParseCommits(row.Commits) + _ = project.ParseMembers(row.Members) ids = append(ids, project.ID) projects = append(projects, project) @@ -222,21 +235,21 @@ func (r *projectRepository) Search(ctx context.Context, params sqlc.SearchProjec for _, row := range rows { project := &models.ProjectEntity{ - ID: convert.UUIDToString(row.ID), - Title: row.Title, - Description: convert.TextToString(row.Description), - LatestRevisionID: convert.UUIDToStringPtr(row.LatestRevisionID), - VersionCount: row.VersionCount, - ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), - LockedBy: convert.UUIDToStringPtr(row.LockedBy), - IsDeleted: row.IsDeleted, - UserID: convert.UUIDToString(row.UserID), - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), - CommitIds: convert.ListUUIDToString(row.CommitIds), - SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), + ID: convert.UUIDToString(row.ID), + Title: row.Title, + Description: convert.TextToString(row.Description), + LatestCommitID: convert.UUIDToStringPtr(row.LatestCommitID), + ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), + LockedBy: convert.UUIDToStringPtr(row.LockedBy), + IsDeleted: row.IsDeleted, + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), } _ = project.ParseUser(row.User) + _ = project.ParseCommits(row.Commits) + _ = project.ParseMembers(row.Members) ids = append(ids, project.ID) projects = append(projects, project) @@ -276,23 +289,21 @@ func (r *projectRepository) Create(ctx context.Context, params sqlc.CreateProjec } project := models.ProjectEntity{ - ID: convert.UUIDToString(row.ID), - Title: row.Title, - Description: convert.TextToString(row.Description), - LatestRevisionID: convert.UUIDToStringPtr(row.LatestRevisionID), - VersionCount: row.VersionCount, - ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), - LockedBy: convert.UUIDToStringPtr(row.LockedBy), - IsDeleted: row.IsDeleted, - UserID: convert.UUIDToString(row.UserID), - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), - CommitIds: convert.ListUUIDToString(row.CommitIds), - SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), + ID: convert.UUIDToString(row.ID), + Title: row.Title, + Description: convert.TextToString(row.Description), + LatestCommitID: convert.UUIDToStringPtr(row.LatestCommitID), + ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), + LockedBy: convert.UUIDToStringPtr(row.LockedBy), + IsDeleted: row.IsDeleted, + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), } _ = project.ParseUser(row.User) - - _ = r.c.Set(ctx, fmt.Sprintf("project:id:%s", project.ID), project, constants.NormalCacheDuration) + _ = project.ParseCommits(row.Commits) + _ = project.ParseMembers(row.Members) go func() { bgCtx := context.Background() @@ -309,23 +320,23 @@ func (r *projectRepository) Update(ctx context.Context, params sqlc.UpdateProjec return nil, err } project := models.ProjectEntity{ - ID: convert.UUIDToString(row.ID), - Title: row.Title, - Description: convert.TextToString(row.Description), - LatestRevisionID: convert.UUIDToStringPtr(row.LatestRevisionID), - VersionCount: row.VersionCount, - ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), - LockedBy: convert.UUIDToStringPtr(row.LockedBy), - IsDeleted: row.IsDeleted, - UserID: convert.UUIDToString(row.UserID), - CreatedAt: convert.TimeToPtr(row.CreatedAt), - UpdatedAt: convert.TimeToPtr(row.UpdatedAt), - CommitIds: convert.ListUUIDToString(row.CommitIds), - SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), + ID: convert.UUIDToString(row.ID), + Title: row.Title, + Description: convert.TextToString(row.Description), + LatestCommitID: convert.UUIDToStringPtr(row.LatestCommitID), + ProjectStatus: constants.ParseProjectStatusType(row.ProjectStatus), + LockedBy: convert.UUIDToStringPtr(row.LockedBy), + IsDeleted: row.IsDeleted, + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + SubmissionIds: convert.ListUUIDToString(row.SubmissionIds), } _ = project.ParseUser(row.User) + _ = project.ParseCommits(row.Commits) + _ = project.ParseMembers(row.Members) - _ = r.c.Set(ctx, fmt.Sprintf("project:id:%s", project.ID), project, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("project:id:%s", project.ID)) return &project, nil } @@ -337,9 +348,75 @@ func (r *projectRepository) Delete(ctx context.Context, id pgtype.UUID) error { _ = r.c.Del(ctx, fmt.Sprintf("project:id:%s", convert.UUIDToString(id))) go func() { bgCtx := context.Background() - _ = r.c.DelByPattern(bgCtx, "project:search*") - _ = r.c.DelByPattern(bgCtx, "project:user*") _ = r.c.DelByPattern(bgCtx, "project:count*") }() return nil } + +func (r *projectRepository) AddMember(ctx context.Context, params sqlc.AddProjectMemberParams) error { + _, err := r.q.AddProjectMember(ctx, params) + if err != nil { + return err + } + _ = r.c.Del(ctx, fmt.Sprintf("project:id:%s", convert.UUIDToString(params.ProjectID))) + _ = r.c.Del(ctx, fmt.Sprintf("project:perm:%s:%s", convert.UUIDToString(params.ProjectID), convert.UUIDToString(params.UserID))) + return nil +} + +func (r *projectRepository) UpdateMemberRole(ctx context.Context, params sqlc.UpdateProjectMemberRoleParams) error { + _, err := r.q.UpdateProjectMemberRole(ctx, params) + if err != nil { + return err + } + _ = r.c.Del(ctx, fmt.Sprintf("project:id:%s", convert.UUIDToString(params.ProjectID))) + _ = r.c.Del(ctx, fmt.Sprintf("project:perm:%s:%s", convert.UUIDToString(params.ProjectID), convert.UUIDToString(params.UserID))) + return nil +} + +func (r *projectRepository) RemoveMember(ctx context.Context, params sqlc.RemoveProjectMemberParams) error { + err := r.q.RemoveProjectMember(ctx, params) + if err != nil { + return err + } + _ = r.c.Del(ctx, fmt.Sprintf("project:id:%s", convert.UUIDToString(params.ProjectID))) + _ = r.c.Del(ctx, fmt.Sprintf("project:perm:%s:%s", convert.UUIDToString(params.ProjectID), convert.UUIDToString(params.UserID))) + return nil +} + +func (r *projectRepository) CheckPermission(ctx context.Context, params sqlc.CheckProjectPermissionParams) (int16, error) { + cacheKey := fmt.Sprintf("project:perm:%s:%s", convert.UUIDToString(params.ProjectID), convert.UUIDToString(params.UserID)) + var role int16 + if err := r.c.Get(ctx, cacheKey, &role); err == nil { + return role, nil + } + + role, err := r.q.CheckProjectPermission(ctx, params) + if err != nil { + return 0, err + } + + _ = r.c.Set(ctx, cacheKey, role, constants.NormalCacheDuration) + return role, nil +} + +func (r *projectRepository) ChangeOwner(ctx context.Context, params sqlc.ChangeProjectOwnerParams) error { + err := r.q.ChangeProjectOwner(ctx, params) + if err != nil { + return err + } + projectID := convert.UUIDToString(params.ID) + _ = r.c.Del(ctx, fmt.Sprintf("project:id:%s", projectID)) + go func() { + _ = r.c.DelByPattern(context.Background(), fmt.Sprintf("project:perm:%s:*", projectID)) + }() + return nil +} + +func (r *projectRepository) UpdateLatestCommit(ctx context.Context, params sqlc.UpdateLatestCommitParams) error { + err := r.q.UpdateLatestCommit(ctx, params) + if err != nil { + return err + } + _ = r.c.Del(ctx, fmt.Sprintf("project:id:%s", convert.UUIDToString(params.ID))) + return nil +} diff --git a/internal/repositories/roleRepository.go b/internal/repositories/roleRepository.go index 30eb5a4..3e83c83 100644 --- a/internal/repositories/roleRepository.go +++ b/internal/repositories/roleRepository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" @@ -18,7 +19,7 @@ import ( type RoleRepository interface { GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) GetByIDs(ctx context.Context, ids []string) ([]*models.RoleEntity, error) - GetByname(ctx context.Context, name string) (*models.RoleEntity, error) + GetByName(ctx context.Context, name string) (*models.RoleEntity, error) All(ctx context.Context) ([]*models.RoleEntity, error) Create(ctx context.Context, name string) (*models.RoleEntity, error) Update(ctx context.Context, params sqlc.UpdateRoleParams) (*models.RoleEntity, error) @@ -28,6 +29,7 @@ type RoleRepository interface { DeleteUserRole(ctx context.Context, params sqlc.DeleteUserRoleParams) error BulkDeleteRolesFromUser(ctx context.Context, userId pgtype.UUID) error BulkDeleteUsersFromRole(ctx context.Context, roleId pgtype.UUID) error + WithTx(tx pgx.Tx) RoleRepository } type roleRepository struct { @@ -42,6 +44,13 @@ func NewRoleRepository(db sqlc.DBTX, c cache.Cache) RoleRepository { } } +func (r *roleRepository) WithTx(tx pgx.Tx) RoleRepository { + return &roleRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + func (r *roleRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) @@ -140,7 +149,7 @@ func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.R return &role, nil } -func (r *roleRepository) GetByname(ctx context.Context, name string) (*models.RoleEntity, error) { +func (r *roleRepository) GetByName(ctx context.Context, name string) (*models.RoleEntity, error) { cacheId := fmt.Sprintf("role:name:%s", name) var role models.RoleEntity err := r.c.Get(ctx, cacheId, &role) @@ -183,11 +192,6 @@ func (r *roleRepository) Create(ctx context.Context, name string) (*models.RoleE CreatedAt: convert.TimeToPtr(row.CreatedAt), UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - mapCache := map[string]any{ - fmt.Sprintf("role:name:%s", name): role, - fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role, - } - _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) return &role, nil } @@ -204,11 +208,7 @@ func (r *roleRepository) Update(ctx context.Context, params sqlc.UpdateRoleParam UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - mapCache := map[string]any{ - fmt.Sprintf("role:name:%s", row.Name): role, - fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role, - } - _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)), fmt.Sprintf("role:name:%s", row.Name)) return &role, nil } diff --git a/internal/repositories/submissionRepository.go b/internal/repositories/submissionRepository.go new file mode 100644 index 0000000..4a3e77e --- /dev/null +++ b/internal/repositories/submissionRepository.go @@ -0,0 +1,313 @@ +package repositories + +import ( + "context" + "crypto/md5" + "encoding/json" + "fmt" + "history-api/internal/gen/sqlc" + "history-api/internal/models" + "history-api/pkg/cache" + "history-api/pkg/constants" + "history-api/pkg/convert" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type SubmissionRepository interface { + GetByID(ctx context.Context, id pgtype.UUID) (*models.SubmissionEntity, error) + GetByIDs(ctx context.Context, ids []string) ([]*models.SubmissionEntity, error) + Search(ctx context.Context, params sqlc.SearchSubmissionsParams) ([]*models.SubmissionEntity, error) + Count(ctx context.Context, params sqlc.CountSubmissionsParams) (int64, error) + Create(ctx context.Context, params sqlc.CreateSubmissionParams) (*models.SubmissionEntity, error) + Update(ctx context.Context, params sqlc.UpdateSubmissionParams) (*models.SubmissionEntity, error) + Delete(ctx context.Context, id pgtype.UUID) error + WithTx(tx pgx.Tx) SubmissionRepository +} + +type submissionRepository struct { + q *sqlc.Queries + c cache.Cache +} + +func NewSubmissionRepository(db sqlc.DBTX, c cache.Cache) SubmissionRepository { + return &submissionRepository{ + q: sqlc.New(db), + c: c, + } +} + +func (r *submissionRepository) WithTx(tx pgx.Tx) SubmissionRepository { + return &submissionRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + +func (r *submissionRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.SubmissionEntity, error) { + cacheId := fmt.Sprintf("submission:id:%s", convert.UUIDToString(id)) + var submission models.SubmissionEntity + err := r.c.Get(ctx, cacheId, &submission) + if err == nil { + return &submission, nil + } + + row, err := r.q.GetSubmissionById(ctx, id) + if err != nil { + return nil, err + } + entity := &models.SubmissionEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + CommitID: convert.UUIDToString(row.CommitID), + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + Status: constants.ParseStatusType(row.Status), + ReviewedBy: convert.UUIDToStringPtr(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + ReviewNote: convert.TextToPtr(row.ReviewNote), + Content: convert.TextToPtr(row.Content), + IsDeleted: row.IsDeleted, + } + _ = entity.ParseUser(row.User) + _ = entity.ParseReviewer(row.Reviewer) + + _ = r.c.Set(ctx, cacheId, entity, constants.NormalCacheDuration) + return entity, nil +} + +func (r *submissionRepository) generateQueryKey(prefix string, params any) string { + b, _ := json.Marshal(params) + hash := fmt.Sprintf("%x", md5.Sum(b)) + return fmt.Sprintf("%s:%s", prefix, hash) +} + +func (r *submissionRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.SubmissionEntity, error) { + if len(ids) == 0 { + return []*models.SubmissionEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("submission:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var submission []*models.SubmissionEntity + missingSubmisstionToCache := make(map[string]any) + + var missingPgIds []pgtype.UUID + for i, b := range raws { + if len(b) == 0 { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err == nil { + missingPgIds = append(missingPgIds, pgId) + } + } + } + + dbMap := make(map[string]*models.SubmissionEntity) + if len(missingPgIds) > 0 { + dbRows, err := r.q.GetSubmissionsByIDs(ctx, missingPgIds) + if err == nil { + for _, row := range dbRows { + entity := &models.SubmissionEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + CommitID: convert.UUIDToString(row.CommitID), + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + Status: constants.ParseStatusType(row.Status), + ReviewedBy: convert.UUIDToStringPtr(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + ReviewNote: convert.TextToPtr(row.ReviewNote), + Content: convert.TextToPtr(row.Content), + IsDeleted: row.IsDeleted, + } + _ = entity.ParseUser(row.User) + _ = entity.ParseReviewer(row.Reviewer) + dbMap[entity.ID] = entity + } + } + } + + for i, b := range raws { + if len(b) > 0 { + var u models.SubmissionEntity + if err := json.Unmarshal(b, &u); err == nil { + submission = append(submission, &u) + } + } else { + if item, ok := dbMap[ids[i]]; ok { + submission = append(submission, item) + missingSubmisstionToCache[keys[i]] = item + } + } + } + + if len(missingSubmisstionToCache) > 0 { + _ = r.c.MSet(ctx, missingSubmisstionToCache, constants.NormalCacheDuration) + } + + return submission, nil +} + +func (r *submissionRepository) GetByIDs(ctx context.Context, ids []string) ([]*models.SubmissionEntity, error) { + return r.getByIDsWithFallback(ctx, ids) +} + +func (r *submissionRepository) Search(ctx context.Context, params sqlc.SearchSubmissionsParams) ([]*models.SubmissionEntity, error) { + queryKey := r.generateQueryKey("verification:search", params) + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + listItem, err := r.getByIDsWithFallback(ctx, cachedIDs) + if err != nil { + return nil, err + } + newCachedIDs := make([]string, len(listItem)) + for i, media := range listItem { + newCachedIDs[i] = media.ID + } + _ = r.c.Set(ctx, queryKey, newCachedIDs, constants.ListCacheDuration) + return listItem, err + } + + rows, err := r.q.SearchSubmissions(ctx, params) + if err != nil { + return nil, err + } + var items []*models.SubmissionEntity + var ids []string + itemToCache := make(map[string]any) + + for _, row := range rows { + entity := &models.SubmissionEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + CommitID: convert.UUIDToString(row.CommitID), + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + Status: constants.ParseStatusType(row.Status), + ReviewedBy: convert.UUIDToStringPtr(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + ReviewNote: convert.TextToPtr(row.ReviewNote), + Content: convert.TextToPtr(row.Content), + IsDeleted: row.IsDeleted, + } + _ = entity.ParseUser(row.User) + _ = entity.ParseReviewer(row.Reviewer) + + ids = append(ids, entity.ID) + items = append(items, entity) + + itemToCache[fmt.Sprintf("submission:id:%s", entity.ID)] = entity + } + + if len(itemToCache) > 0 { + _ = r.c.MSet(ctx, itemToCache, constants.NormalCacheDuration) + } + + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return items, nil +} + +func (r *submissionRepository) Count(ctx context.Context, params sqlc.CountSubmissionsParams) (int64, error) { + queryKey := r.generateQueryKey("submission:count", params) + var count int64 + if err := r.c.Get(ctx, queryKey, &count); err == nil { + _ = r.c.Set(ctx, queryKey, count, constants.ListCacheDuration) + return count, nil + } + count, err := r.q.CountSubmissions(ctx, params) + if err != nil { + return 0, err + } + _ = r.c.Set(ctx, queryKey, count, constants.ListCacheDuration) + return count, nil +} + +func (r *submissionRepository) Create(ctx context.Context, params sqlc.CreateSubmissionParams) (*models.SubmissionEntity, error) { + row, err := r.q.CreateSubmission(ctx, params) + if err != nil { + return nil, err + } + submission := models.SubmissionEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + CommitID: convert.UUIDToString(row.CommitID), + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + Status: constants.ParseStatusType(row.Status), + ReviewedBy: convert.UUIDToStringPtr(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + ReviewNote: convert.TextToPtr(row.ReviewNote), + Content: convert.TextToPtr(row.Content), + IsDeleted: row.IsDeleted, + } + + if err := submission.ParseUser(row.User); err != nil { + return nil, err + } + + if err := submission.ParseReviewer(row.Reviewer); err != nil { + return nil, err + } + + go func() { + bgCtx := context.Background() + _ = r.c.DelByPattern(bgCtx, "submission:search*") + _ = r.c.DelByPattern(bgCtx, "submission:count*") + }() + + return &submission, nil +} + +func (r *submissionRepository) Update(ctx context.Context, params sqlc.UpdateSubmissionParams) (*models.SubmissionEntity, error) { + row, err := r.q.UpdateSubmission(ctx, params) + if err != nil { + return nil, err + } + + submission := models.SubmissionEntity{ + ID: convert.UUIDToString(row.ID), + ProjectID: convert.UUIDToString(row.ProjectID), + CommitID: convert.UUIDToString(row.CommitID), + UserID: convert.UUIDToString(row.UserID), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + Status: constants.ParseStatusType(row.Status), + ReviewedBy: convert.UUIDToStringPtr(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + ReviewNote: convert.TextToPtr(row.ReviewNote), + Content: convert.TextToPtr(row.Content), + IsDeleted: row.IsDeleted, + } + + if err := submission.ParseUser(row.User); err != nil { + return nil, err + } + + if err := submission.ParseReviewer(row.Reviewer); err != nil { + return nil, err + } + + _ = r.c.Del(ctx, fmt.Sprintf("submission:id:%s", submission.ID)) + + return &submission, nil +} + +func (r *submissionRepository) Delete(ctx context.Context, id pgtype.UUID) error { + err := r.q.DeleteSubmission(ctx, id) + if err != nil { + return err + } + + _ = r.c.Del(ctx, fmt.Sprintf("submission:id:%s", convert.UUIDToString(id))) + go func() { + _ = r.c.DelByPattern(context.Background(), "submission:count*") + }() + return nil +} diff --git a/internal/repositories/userRepository.go b/internal/repositories/userRepository.go index d6ede42..09f32bb 100644 --- a/internal/repositories/userRepository.go +++ b/internal/repositories/userRepository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" @@ -30,6 +31,7 @@ type UserRepository interface { UpdateTokenVersion(ctx context.Context, params sqlc.UpdateTokenVersionParams) error Delete(ctx context.Context, id pgtype.UUID) error Restore(ctx context.Context, id pgtype.UUID) error + WithTx(tx pgx.Tx) UserRepository } type userRepository struct { @@ -44,6 +46,13 @@ func NewUserRepository(db sqlc.DBTX, c cache.Cache) UserRepository { } } +func (r *userRepository) WithTx(tx pgx.Tx) UserRepository { + return &userRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + func (r *userRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) @@ -270,11 +279,7 @@ func (r *userRepository) UpdateProfile(ctx context.Context, params sqlc.UpdateUs } user.Profile = &profile - mapCache := map[string]any{ - fmt.Sprintf("user:email:%s", user.Email): user, - fmt.Sprintf("user:id:%s", user.ID): user, - } - _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("user:email:%s", user.Email), fmt.Sprintf("user:id:%s", user.ID)) return user, nil } @@ -391,11 +396,12 @@ func (r *userRepository) Restore(ctx context.Context, id pgtype.UUID) error { if err != nil { return err } - - _ = r.c.Del(ctx, fmt.Sprintf("user:id:%s", convert.UUIDToString(id))) - _ = r.c.Del(ctx, fmt.Sprintf("user:email:%s", convert.UUIDToString(id))) - _ = r.c.Del(ctx, fmt.Sprintf("user:deleted:id:%s", convert.UUIDToString(id))) - + _ = r.c.Del( + ctx, + fmt.Sprintf("user:deleted:id:%s", convert.UUIDToString(id)), + fmt.Sprintf("user:email:%s", convert.UUIDToString(id)), + fmt.Sprintf("user:id:%s", convert.UUIDToString(id)), + ) return nil } @@ -421,9 +427,7 @@ func (r *userRepository) UpdateTokenVersion(ctx context.Context, params sqlc.Upd if err != nil { return err } - - cacheId := fmt.Sprintf("user:token:%s", convert.UUIDToString(params.ID)) - _ = r.c.Set(ctx, cacheId, params.TokenVersion, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("user:token:%s", convert.UUIDToString(params.ID))) return nil } @@ -436,23 +440,7 @@ func (r *userRepository) UpdatePassword(ctx context.Context, params sqlc.UpdateU if err != nil { return err } - err = r.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ - ID: params.ID, - TokenVersion: user.TokenVersion + 1, - }) - if err != nil { - return err - } - - user.PasswordHash = convert.TextToString(params.PasswordHash) - user.TokenVersion += 1 - mapCache := map[string]any{ - fmt.Sprintf("user:email:%s", user.Email): user, - fmt.Sprintf("user:id:%s", user.ID): user, - fmt.Sprintf("user:token:%s", user.ID): user.TokenVersion, - } - - _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("user:email:%s", user.Email), fmt.Sprintf("user:id:%s", user.ID)) return nil } @@ -467,12 +455,7 @@ func (r *userRepository) UpdateRefreshToken(ctx context.Context, params sqlc.Upd } user.RefreshToken = convert.TextToString(params.RefreshToken) - mapCache := map[string]any{ - fmt.Sprintf("user:email:%s", user.Email): user, - fmt.Sprintf("user:id:%s", user.ID): user, - fmt.Sprintf("user:token:%s", user.ID): user.TokenVersion, - } - _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("user:email:%s", user.Email), fmt.Sprintf("user:id:%s", user.ID)) return nil } diff --git a/internal/repositories/verificationRepository.go b/internal/repositories/verificationRepository.go index f421eb3..ee29c99 100644 --- a/internal/repositories/verificationRepository.go +++ b/internal/repositories/verificationRepository.go @@ -11,6 +11,7 @@ import ( "history-api/pkg/constants" "history-api/pkg/convert" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -25,6 +26,7 @@ type VerificationRepository interface { CreateVerificationMedia(ctx context.Context, params sqlc.CreateVerificationMediaParams) error DeleteVerificationMedia(ctx context.Context, params sqlc.DeleteVerificationMediaParams) error BulkVerificationMediaByMediaId(ctx context.Context, mediaId pgtype.UUID) error + WithTx(tx pgx.Tx) VerificationRepository } type verificationRepository struct { @@ -39,6 +41,13 @@ func NewVerificationRepository(db sqlc.DBTX, c cache.Cache) VerificationReposito } } +func (v *verificationRepository) WithTx(tx pgx.Tx) VerificationRepository { + return &verificationRepository{ + q: v.q.WithTx(tx), + c: v.c, + } +} + func (v *verificationRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) @@ -174,13 +183,6 @@ func (v *verificationRepository) Create(ctx context.Context, params sqlc.CreateU if err != nil { return nil, err } - - go func() { - bgCtx := context.Background() - _ = v.c.DelByPattern(bgCtx, "verification:search*") - _ = v.c.DelByPattern(bgCtx, "verification:count*") - }() - verification := models.UserVerificationEntity{ ID: convert.UUIDToString(row.ID), VerifyType: constants.ParseVerifyType(row.VerifyType), @@ -204,9 +206,9 @@ func (v *verificationRepository) Create(ctx context.Context, params sqlc.CreateU return nil, err } - _ = v.c.Del(ctx, fmt.Sprintf("verification:userId:%s", convert.UUIDToString(params.UserID))) go func() { bgCtx := context.Background() + _ = v.c.DelByPattern(bgCtx, "verification:search*") _ = v.c.DelByPattern(bgCtx, "verification:count*") }() @@ -218,10 +220,7 @@ func (v *verificationRepository) UpdateStatus(ctx context.Context, params sqlc.U if err != nil { return err } - - cacheId := fmt.Sprintf("verification:id:%s", convert.UUIDToString(params.ID)) - _ = v.c.Del(ctx, cacheId) - + _ = v.c.Del(ctx, fmt.Sprintf("verification:id:%s", convert.UUIDToString(params.ID))) return nil } @@ -231,9 +230,10 @@ func (v *verificationRepository) Delete(ctx context.Context, id pgtype.UUID) err return err } - cacheId := fmt.Sprintf("verification:id:%s", convert.UUIDToString(id)) - _ = v.c.Del(ctx, cacheId) - + _ = v.c.Del(ctx, fmt.Sprintf("verification:id:%s", convert.UUIDToString(id))) + go func() { + _ = v.c.DelByPattern(context.Background(), "verification:count*") + }() return nil } @@ -243,6 +243,10 @@ func (v *verificationRepository) BulkVerificationMediaByMediaId(ctx context.Cont return err } + if len(ids) == 0 { + return nil + } + listCacheId := make([]string, 0) for _, it := range ids { id := convert.UUIDToString(it) diff --git a/internal/repositories/wikiRepository.go b/internal/repositories/wikiRepository.go index 3a57d6b..290d7c3 100644 --- a/internal/repositories/wikiRepository.go +++ b/internal/repositories/wikiRepository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" @@ -24,6 +25,7 @@ type WikiRepository interface { Delete(ctx context.Context, id pgtype.UUID) error CreateEntityWikis(ctx context.Context, params sqlc.CreateEntityWikisParams) error BulkDeleteEntityWikisByEntityId(ctx context.Context, entityId pgtype.UUID) error + WithTx(tx pgx.Tx) WikiRepository } type wikiRepository struct { @@ -38,6 +40,13 @@ func NewWikiRepository(db sqlc.DBTX, c cache.Cache) WikiRepository { } } +func (r *wikiRepository) WithTx(tx pgx.Tx) WikiRepository { + return &wikiRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + func (r *wikiRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) @@ -191,11 +200,9 @@ func (r *wikiRepository) Create(ctx context.Context, params sqlc.CreateWikiParam CreatedAt: convert.TimeToPtr(row.CreatedAt), UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, fmt.Sprintf("wiki:id:%s", wiki.ID), wiki, constants.NormalCacheDuration) go func() { - bgCtx := context.Background() - _ = r.c.DelByPattern(bgCtx, "wiki:search*") + _ = r.c.DelByPattern(context.Background(), "wiki:search*") }() return &wiki, nil @@ -214,7 +221,7 @@ func (r *wikiRepository) Update(ctx context.Context, params sqlc.UpdateWikiParam CreatedAt: convert.TimeToPtr(row.CreatedAt), UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, fmt.Sprintf("wiki:id:%s", wiki.ID), wiki, constants.NormalCacheDuration) + _ = r.c.Del(ctx, fmt.Sprintf("wiki:id:%s", wiki.ID)) return &wiki, nil } @@ -224,9 +231,6 @@ func (r *wikiRepository) Delete(ctx context.Context, id pgtype.UUID) error { return err } _ = r.c.Del(ctx, fmt.Sprintf("wiki:id:%s", convert.UUIDToString(id))) - go func() { - _ = r.c.DelByPattern(context.Background(), "wiki:search*") - }() return nil } diff --git a/internal/routes/projectRoute.go b/internal/routes/projectRoute.go index 9da48d6..3a5b4b7 100644 --- a/internal/routes/projectRoute.go +++ b/internal/routes/projectRoute.go @@ -8,9 +8,55 @@ import ( "github.com/gofiber/fiber/v3" ) -func ProjectRoutes(app *fiber.App, controller *controllers.ProjectController, userRepo repositories.UserRepository) { +func ProjectRoutes( + app *fiber.App, + controller *controllers.ProjectController, + commitController *controllers.CommitController, + userRepo repositories.UserRepository, +) { route := app.Group("/projects") + route.Post( + "/:id/commits", + middlewares.JwtAccess(userRepo), + commitController.CreateCommit, + ) + + route.Post( + "/:id/commits/restore", + middlewares.JwtAccess(userRepo), + commitController.RestoreCommit, + ) + + route.Get( + "/:id/commits", + commitController.GetProjectCommits, + ) + + route.Post( + "/:id/members", + middlewares.JwtAccess(userRepo), + controller.AddMember, + ) + + route.Put( + "/:id/members/:userId", + middlewares.JwtAccess(userRepo), + controller.UpdateMemberRole, + ) + + route.Delete( + "/:id/members/:userId", + middlewares.JwtAccess(userRepo), + controller.RemoveMember, + ) + + route.Put( + "/:id/change-owner", + middlewares.JwtAccess(userRepo), + controller.ChangeOwner, + ) + route.Get( "/:id", controller.GetProjectByID, diff --git a/internal/routes/submissionRoute.go b/internal/routes/submissionRoute.go new file mode 100644 index 0000000..de2387f --- /dev/null +++ b/internal/routes/submissionRoute.go @@ -0,0 +1,51 @@ +package routes + +import ( + "history-api/internal/controllers" + "history-api/internal/middlewares" + "history-api/internal/repositories" + "history-api/pkg/constants" + + "github.com/gofiber/fiber/v3" +) + +func SubmissionRoutes( + app *fiber.App, + controller controllers.SubmissionController, + userRepo repositories.UserRepository, +) { + route := app.Group("/submissions") + + route.Patch( + "/:id/status", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.RoleTypeAdmin, constants.RoleTypeMod), + controller.UpdateSubmissionStatus, + ) + + route.Get( + "/:id", + middlewares.JwtAccess(userRepo), + controller.GetSubmissionByID, + ) + + route.Delete( + "/:id", + middlewares.JwtAccess(userRepo), + controller.DeleteSubmission, + ) + + route.Post( + "/", + middlewares.JwtAccess(userRepo), + controller.CreateSubmission, + ) + + route.Get( + "/", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.RoleTypeAdmin, constants.RoleTypeMod), + controller.SearchSubmissions, + ) + +} diff --git a/internal/services/authService.go b/internal/services/authService.go index 1a6f71b..e629603 100644 --- a/internal/services/authService.go +++ b/internal/services/authService.go @@ -27,6 +27,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" ) @@ -46,6 +47,7 @@ type authService struct { roleRepo repositories.RoleRepository tokenRepo repositories.TokenRepository c cache.Cache + db *pgxpool.Pool } func NewAuthService( @@ -53,12 +55,14 @@ func NewAuthService( roleRepo repositories.RoleRepository, tokenRepo repositories.TokenRepository, c cache.Cache, + db *pgxpool.Pool, ) AuthService { return &authService{ userRepo: userRepo, roleRepo: roleRepo, tokenRepo: tokenRepo, c: c, + db: db, } } @@ -113,14 +117,6 @@ func (a *authService) genToken(user *models.UserEntity) (*response.AuthResponse, return &res, nil } -func (a *authService) saveNewRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error { - err := a.userRepo.UpdateRefreshToken(ctx, params) - if err != nil { - return err - } - return nil -} - func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) { if !constants.EMAIL_REGEX.MatchString(dto.Email) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email") @@ -153,7 +149,7 @@ func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*resp if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = a.saveNewRefreshToken( + err = a.userRepo.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, @@ -172,24 +168,32 @@ func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*resp } func (a *authService) Logout(ctx context.Context, userId string) error { + tx, err := a.db.Begin(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + defer tx.Rollback(ctx) + + uRepoTx := a.userRepo.WithTx(tx) + pgID, err := convert.StringToUUID(userId) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - user , err := a.userRepo.GetByID(ctx, pgID) + user, err := a.userRepo.GetByID(ctx, pgID) if err != nil || user == nil { return fiber.NewError(fiber.StatusInternalServerError, "Invalid user data") } - - err = a.userRepo.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ - ID: pgID, + + err = uRepoTx.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ + ID: pgID, TokenVersion: user.TokenVersion + 1, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = a.userRepo.UpdateRefreshToken(ctx, sqlc.UpdateUserRefreshTokenParams{ + err = uRepoTx.UpdateRefreshToken(ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, RefreshToken: pgtype.Text{ String: "", @@ -199,6 +203,10 @@ func (a *authService) Logout(ctx context.Context, userId string) error { if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } + err = tx.Commit(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } return nil } @@ -228,7 +236,7 @@ func (a *authService) RefreshToken(ctx context.Context, id string, refreshToken return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = a.saveNewRefreshToken( + err = a.userRepo.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, @@ -246,10 +254,19 @@ func (a *authService) RefreshToken(ctx context.Context, id string, refreshToken } func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) { + tx, err := a.db.Begin(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + defer tx.Rollback(ctx) + + uRepoTx := a.userRepo.WithTx(tx) + rRepoTx := a.roleRepo.WithTx(tx) + if !constants.EMAIL_REGEX.MatchString(dto.Email) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email") } - err := constants.ValidatePassword(dto.Password) + err = constants.ValidatePassword(dto.Password) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) } @@ -276,7 +293,7 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - user, err = a.userRepo.UpsertUser( + user, err = uRepoTx.UpsertUser( ctx, sqlc.UpsertUserParams{ Email: dto.Email, @@ -295,7 +312,7 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - _, err = a.userRepo.CreateProfile( + _, err = uRepoTx.CreateProfile( ctx, sqlc.CreateUserProfileParams{ UserID: userId, @@ -308,7 +325,7 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - role, err := a.roleRepo.GetByname(ctx, constants.RoleTypeUser.String()) + role, err := a.roleRepo.GetByName(ctx, constants.RoleTypeUser.String()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } @@ -318,7 +335,7 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = a.roleRepo.CreateUserRole( + err = rRepoTx.CreateUserRole( ctx, sqlc.CreateUserRoleParams{ UserID: userId, @@ -334,7 +351,7 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = a.saveNewRefreshToken( + err = uRepoTx.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, @@ -348,6 +365,11 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } + err = tx.Commit(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return data, nil } @@ -389,6 +411,15 @@ func (a *authService) ForgotPassword(ctx context.Context, dto *request.ForgotPas } func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninWithGoogleDto) (*response.AuthResponse, error) { + tx, err := a.db.Begin(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + defer tx.Rollback(ctx) + + uRepoTx := a.userRepo.WithTx(tx) + rRepoTx := a.roleRepo.WithTx(tx) + user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) @@ -403,7 +434,7 @@ func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninW if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = a.saveNewRefreshToken( + err = uRepoTx.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, @@ -419,7 +450,7 @@ func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninW return data, nil } - user, err = a.userRepo.UpsertUser( + user, err = uRepoTx.UpsertUser( ctx, sqlc.UpsertUserParams{ Email: dto.Email, @@ -437,7 +468,7 @@ func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninW if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - _, err = a.userRepo.CreateProfile( + _, err = uRepoTx.CreateProfile( ctx, sqlc.CreateUserProfileParams{ UserID: userId, @@ -454,7 +485,7 @@ func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninW if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - role, err := a.roleRepo.GetByname(ctx, constants.RoleTypeUser.String()) + role, err := a.roleRepo.GetByName(ctx, constants.RoleTypeUser.String()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } @@ -464,7 +495,7 @@ func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninW return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = a.roleRepo.CreateUserRole( + err = rRepoTx.CreateUserRole( ctx, sqlc.CreateUserRoleParams{ UserID: userId, @@ -479,7 +510,7 @@ func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninW if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = a.saveNewRefreshToken( + err = uRepoTx.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, @@ -492,6 +523,10 @@ func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninW if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } + err = tx.Commit(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } return data, nil } diff --git a/internal/services/commitService.go b/internal/services/commitService.go new file mode 100644 index 0000000..ac3148b --- /dev/null +++ b/internal/services/commitService.go @@ -0,0 +1,168 @@ +package services + +import ( + "context" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/gen/sqlc" + "history-api/internal/models" + "history-api/internal/repositories" + "history-api/pkg/constants" + "history-api/pkg/convert" + + "github.com/gofiber/fiber/v3" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type CommitService interface { + CreateCommit(ctx context.Context, userID string, projectID string, dto *request.CreateCommitDto) (*response.CommitResponse, error) + RestoreCommit(ctx context.Context, userID string, projectID string, dto *request.RestoreCommitDto) error + GetProjectCommits(ctx context.Context, projectID string) ([]*response.CommitResponse, error) +} + +type commitService struct { + db *pgxpool.Pool + commitRepo repositories.CommitRepository + projectRepo repositories.ProjectRepository +} + +func NewCommitService( + db *pgxpool.Pool, + commitRepo repositories.CommitRepository, + projectRepo repositories.ProjectRepository, +) CommitService { + return &commitService{ + db: db, + commitRepo: commitRepo, + projectRepo: projectRepo, + } +} +func (s *commitService) checkWritePermission(ctx context.Context, userID string, projectUUID pgtype.UUID) error { + project, err := s.projectRepo.GetByID(ctx, projectUUID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, "Project not found") + } + + if project.UserID == userID { + return nil + } + + userUUID, _ := convert.StringToUUID(userID) + roleVal, err := s.projectRepo.CheckPermission(ctx, sqlc.CheckProjectPermissionParams{ + ProjectID: projectUUID, + UserID: userUUID, + }) + if err != nil { + return fiber.NewError(fiber.StatusForbidden, "You do not have permission to write to this project") + } + + role := constants.ParseProjectMemberRole(roleVal) + if !role.CanWrite() { + return fiber.NewError(fiber.StatusForbidden, "You do not have permission to write to this project") + } + + return nil +} + +func (s *commitService) CreateCommit(ctx context.Context, userID string, projectID string, dto *request.CreateCommitDto) (*response.CommitResponse, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + defer tx.Rollback(ctx) + + cRepoTx := s.commitRepo.WithTx(tx) + pRepoTx := s.projectRepo.WithTx(tx) + + projectUUID, err := convert.StringToUUID(projectID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID") + } + + if err := s.checkWritePermission(ctx, userID, projectUUID); err != nil { + return nil, err + } + + userUUID, err := convert.StringToUUID(userID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid user ID") + } + + commit, err := cRepoTx.Create(ctx, sqlc.CreateCommitParams{ + ProjectID: projectUUID, + SnapshotJson: dto.SnapshotJson, + UserID: userUUID, + EditSummary: convert.StringToText(dto.EditSummary), + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create commit") + } + + commitUUID, err := convert.StringToUUID(commit.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid commit ID") + } + + err = pRepoTx.UpdateLatestCommit(ctx, sqlc.UpdateLatestCommitParams{ + ID: projectUUID, + LatestCommitID: commitUUID, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project latest commit") + } + + if err := tx.Commit(ctx); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + + return commit.ToResponse(), nil +} + +func (s *commitService) RestoreCommit(ctx context.Context, userID string, projectID string, dto *request.RestoreCommitDto) error { + projectUUID, err := convert.StringToUUID(projectID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project ID") + } + + if err := s.checkWritePermission(ctx, userID, projectUUID); err != nil { + return err + } + + commitUUID, err := convert.StringToUUID(dto.CommitID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid commit ID") + } + + commit, err := s.commitRepo.GetByID(ctx, commitUUID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, "Commit not found") + } + + if commit.ProjectID != projectID { + return fiber.NewError(fiber.StatusBadRequest, "Commit does not belong to this project") + } + + err = s.projectRepo.UpdateLatestCommit(ctx, sqlc.UpdateLatestCommitParams{ + ID: projectUUID, + LatestCommitID: commitUUID, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore commit") + } + return nil +} + +func (s *commitService) GetProjectCommits(ctx context.Context, projectID string) ([]*response.CommitResponse, error) { + projectUUID, err := convert.StringToUUID(projectID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID") + } + + commits, err := s.commitRepo.GetByProjectID(ctx, projectUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch commits") + } + + return models.CommitsEntityToResponse(commits), nil +} diff --git a/internal/services/projectService.go b/internal/services/projectService.go index 3016079..e79350d 100644 --- a/internal/services/projectService.go +++ b/internal/services/projectService.go @@ -2,7 +2,6 @@ package services import ( "context" - "fmt" "github.com/gofiber/fiber/v3" "github.com/jackc/pgx/v5/pgtype" @@ -24,6 +23,10 @@ type ProjectService interface { DeleteProject(ctx context.Context, id string) error CreateProject(ctx context.Context, userID string, dto *request.CreateProjectDto) (*response.ProjectResponse, error) UpdateProject(ctx context.Context, id string, dto *request.UpdateProjectDto) (*response.ProjectResponse, error) + AddMember(ctx context.Context, callerID string, projectID string, dto *request.AddProjectMemberDto) (*response.ProjectResponse, error) + UpdateMemberRole(ctx context.Context, callerID string, projectID string, memberUserID string, dto *request.UpdateProjectMemberDto) (*response.ProjectResponse, error) + RemoveMember(ctx context.Context, callerID string, projectID string, memberUserID string) error + ChangeOwner(ctx context.Context, callerID string, projectID string, newOwnerID string) (*response.ProjectResponse, error) } type projectService struct { @@ -36,6 +39,25 @@ func NewProjectService(projectRepo repositories.ProjectRepository) ProjectServic } } +func (s *projectService) checkCallerIsOwner(ctx context.Context, callerID string, projectUUID pgtype.UUID) error { + project, err := s.projectRepo.GetByID(ctx, projectUUID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, "Project not found") + } + if project.UserID == callerID { + return nil + } + callerUUID, _ := convert.StringToUUID(callerID) + role, err := s.projectRepo.CheckPermission(ctx, sqlc.CheckProjectPermissionParams{ + ProjectID: projectUUID, + UserID: callerUUID, + }) + if err != nil || constants.ParseProjectMemberRole(role) != constants.ProjectMemberRoleOwner { + return fiber.NewError(fiber.StatusForbidden, "Only project owner can perform this action") + } + return nil +} + func (s *projectService) GetProjectByID(ctx context.Context, id string) (*response.ProjectResponse, error) { projectUUID, err := convert.StringToUUID(id) if err != nil { @@ -96,7 +118,7 @@ func (s *projectService) fillSearchArgs(arg *sqlc.SearchProjectsParams, dto *req for _, statusStr := range dto.Statuses { statusType := constants.ParseProjectStatusTypeText(statusStr) if statusType != constants.ProjectStatusTypeUnknow { - arg.Statuses = append(arg.Statuses, fmt.Sprintf("%d", statusType.Int16())) + arg.Statuses = append(arg.Statuses, statusType.Int16()) } } } @@ -211,15 +233,11 @@ func (s *projectService) UpdateProject(ctx context.Context, id string, dto *requ } arg := sqlc.UpdateProjectParams{ - ID: projectUUID, + ID: projectUUID, + Title: convert.PtrToText(dto.Title), + Description: convert.PtrToText(dto.Description), } - if dto.Title != nil { - arg.Title = convert.PtrToText(dto.Title) - } - if dto.Description != nil { - arg.Description = convert.PtrToText(dto.Description) - } if dto.Status != nil { statusType := constants.ParseProjectStatusTypeText(*dto.Status) if statusType == constants.ProjectStatusTypeUnknow { @@ -254,3 +272,143 @@ func (s *projectService) DeleteProject(ctx context.Context, id string) error { return nil } + +func (s *projectService) AddMember(ctx context.Context, callerID string, projectID string, dto *request.AddProjectMemberDto) (*response.ProjectResponse, error) { + projectUUID, err := convert.StringToUUID(projectID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") + } + + if err := s.checkCallerIsOwner(ctx, callerID, projectUUID); err != nil { + return nil, err + } + + memberUUID, err := convert.StringToUUID(dto.UserID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid member user ID format") + } + + callerUUID, _ := convert.StringToUUID(callerID) + + if dto.UserID == callerID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Cannot add yourself as a member") + } + + role := constants.ParseProjectMemberRoleText(dto.Role) + + err = s.projectRepo.AddMember(ctx, sqlc.AddProjectMemberParams{ + ProjectID: projectUUID, + UserID: memberUUID, + Role: role.Int16(), + InvitedBy: callerUUID, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusConflict, "Member already exists or user not found") + } + + project, err := s.projectRepo.GetByID(ctx, projectUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch updated project") + } + + return project.ToResponse(), nil +} + +func (s *projectService) UpdateMemberRole(ctx context.Context, callerID string, projectID string, memberUserID string, dto *request.UpdateProjectMemberDto) (*response.ProjectResponse, error) { + projectUUID, err := convert.StringToUUID(projectID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") + } + + if err := s.checkCallerIsOwner(ctx, callerID, projectUUID); err != nil { + return nil, err + } + + memberUUID, err := convert.StringToUUID(memberUserID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid member user ID format") + } + + role := constants.ParseProjectMemberRoleText(dto.Role) + + err = s.projectRepo.UpdateMemberRole(ctx, sqlc.UpdateProjectMemberRoleParams{ + ProjectID: projectUUID, + UserID: memberUUID, + Role: role.Int16(), + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Member not found") + } + + project, err := s.projectRepo.GetByID(ctx, projectUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch updated project") + } + + return project.ToResponse(), nil +} + +func (s *projectService) RemoveMember(ctx context.Context, callerID string, projectID string, memberUserID string) error { + projectUUID, err := convert.StringToUUID(projectID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") + } + + if err := s.checkCallerIsOwner(ctx, callerID, projectUUID); err != nil { + return err + } + + memberUUID, err := convert.StringToUUID(memberUserID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid member user ID format") + } + + if callerID == memberUserID { + return fiber.NewError(fiber.StatusBadRequest, "Cannot remove yourself from the project") + } + + err = s.projectRepo.RemoveMember(ctx, sqlc.RemoveProjectMemberParams{ + ProjectID: projectUUID, + UserID: memberUUID, + }) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, "Member not found") + } + + return nil +} + +func (s *projectService) ChangeOwner(ctx context.Context, callerID string, projectID string, newOwnerID string) (*response.ProjectResponse, error) { + projectUUID, err := convert.StringToUUID(projectID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") + } + + if err := s.checkCallerIsOwner(ctx, callerID, projectUUID); err != nil { + return nil, err + } + + if callerID == newOwnerID { + return nil, fiber.NewError(fiber.StatusBadRequest, "You are already the owner") + } + + newOwnerUUID, err := convert.StringToUUID(newOwnerID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid new owner ID format") + } + + err = s.projectRepo.ChangeOwner(ctx, sqlc.ChangeProjectOwnerParams{ + ID: projectUUID, + UserID: newOwnerUUID, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to change owner") + } + + project, err := s.projectRepo.GetByID(ctx, projectUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch updated project") + } + + return project.ToResponse(), nil +} diff --git a/internal/services/roleService.go b/internal/services/roleService.go index 505fdc8..bc3bbf2 100644 --- a/internal/services/roleService.go +++ b/internal/services/roleService.go @@ -19,7 +19,6 @@ type roleService struct { roleRepo repositories.RoleRepository } - func NewRoleService( roleRepo repositories.RoleRepository, ) RoleService { @@ -48,4 +47,4 @@ func (r *roleService) GetRoleByID(ctx context.Context, id string) (*response.Rol } return role.ToResponse(), nil -} \ No newline at end of file +} diff --git a/internal/services/submissionService.go b/internal/services/submissionService.go new file mode 100644 index 0000000..d70984b --- /dev/null +++ b/internal/services/submissionService.go @@ -0,0 +1,282 @@ +package services + +import ( + "context" + "fmt" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/gen/sqlc" + "history-api/internal/models" + "history-api/internal/repositories" + "history-api/pkg/cache" + "history-api/pkg/constants" + "history-api/pkg/convert" + "slices" + + "github.com/gofiber/fiber/v3" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/sync/errgroup" +) + +type SubmissionService interface { + CreateSubmission(ctx context.Context, userID string, dto *request.CreateSubmissionDto) (*response.SubmissionResponse, error) + UpdateSubmissionStatus(ctx context.Context, reviewerID string, submissionID string, dto *request.UpdateSubmissionStatusDto) (*response.SubmissionResponse, error) + GetSubmissionByID(ctx context.Context, id string) (*response.SubmissionResponse, error) + SearchSubmissions(ctx context.Context, dto *request.SearchSubmissionDto) (*response.PaginatedResponse, error) + DeleteSubmission(ctx context.Context, userID string, id string, claims *response.JWTClaims) error +} + +type submissionService struct { + submissionRepo repositories.SubmissionRepository + projectRepo repositories.ProjectRepository + commitRepo repositories.CommitRepository + userRepo repositories.UserRepository + db *pgxpool.Pool + c cache.Cache +} + +func NewSubmissionService( + submissionRepo repositories.SubmissionRepository, + projectRepo repositories.ProjectRepository, + commitRepo repositories.CommitRepository, + userRepo repositories.UserRepository, + db *pgxpool.Pool, + c cache.Cache, +) SubmissionService { + return &submissionService{ + submissionRepo: submissionRepo, + projectRepo: projectRepo, + commitRepo: commitRepo, + userRepo: userRepo, + db: db, + c: c, + } +} + +func (s *submissionService) CreateSubmission(ctx context.Context, userID string, dto *request.CreateSubmissionDto) (*response.SubmissionResponse, error) { + projectUUID, err := convert.StringToUUID(dto.ProjectID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project ID") + } + + commitUUID, err := convert.StringToUUID(dto.CommitID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid commit ID") + } + + userUUID, err := convert.StringToUUID(userID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid user ID") + } + + commit, err := s.commitRepo.GetByID(ctx, commitUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Commit not found") + } + if commit.ProjectID != dto.ProjectID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Commit does not belong to project") + } + + arg := sqlc.CreateSubmissionParams{ + ProjectID: projectUUID, + CommitID: commitUUID, + UserID: userUUID, + Status: constants.StatusTypePending.Int16(), + Content: convert.StringToText(dto.Content), + } + + submission, err := s.submissionRepo.Create(ctx, arg) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create submission") + } + + return submission.ToResponse(), nil +} + +func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewerID string, submissionID string, dto *request.UpdateSubmissionStatusDto) (*response.SubmissionResponse, error) { + submissionUUID, err := convert.StringToUUID(submissionID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid submission ID") + } + + reviewerUUID, err := convert.StringToUUID(reviewerID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid reviewer ID") + } + + status := constants.ParseStatusTypeText(dto.Status) + if status == constants.StatusTypeUnknown { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid status") + } + + + submission, err := s.submissionRepo.GetByID(ctx, submissionUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Submission not found") + } + + if submission.Status != constants.StatusTypePending { + return nil, fiber.NewError(fiber.StatusBadRequest, "Submission already processed") + } + + arg := sqlc.UpdateSubmissionParams{ + ID: submissionUUID, + Status: pgtype.Int2{Int16: status.Int16(), Valid: true}, + ReviewedBy: reviewerUUID, + ReviewNote: convert.StringToText(dto.ReviewNote), + } + + updatedSubmission, err := s.submissionRepo.Update(ctx, arg) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update submission status") + } + + _ = s.c.Del(ctx, fmt.Sprintf("proejct:id:%s", submission.ProjectID)) + + + return updatedSubmission.ToResponse(), nil +} + +func (m *submissionService) fillSearchArgs(arg *sqlc.SearchSubmissionsParams, dto *request.SearchSubmissionDto) { + if dto.Sort != "" { + arg.Sort = pgtype.Text{String: dto.Sort, Valid: true} + } else { + arg.Sort = pgtype.Text{String: "id", Valid: true} + } + + arg.Order = pgtype.Text{String: "asc", Valid: true} + if dto.Order == "desc" { + arg.Order = pgtype.Text{String: "desc", Valid: true} + } + + if len(dto.Statuses) > 0 { + for _, id := range dto.Statuses { + if u := constants.ParseStatusTypeText(id); u != constants.StatusTypeUnknown { + arg.Statuses = append(arg.Statuses, u.Int16()) + } + } + } + + if len(dto.UserIDs) > 0 { + for _, id := range dto.UserIDs { + if u, err := convert.StringToUUID(id); err == nil { + arg.UserIds = append(arg.UserIds, u) + } + } + } + + if dto.ReviewedBy != nil { + if rvID, err := convert.StringToUUID(*dto.ReviewedBy); err == nil { + arg.ReviewedBy = rvID + } + } + + if dto.CreatedFrom != nil { + arg.CreatedFrom = pgtype.Timestamptz{Time: *dto.CreatedFrom, Valid: true} + } + + if dto.CreatedTo != nil { + arg.CreatedTo = pgtype.Timestamptz{Time: *dto.CreatedTo, Valid: true} + } + + if dto.Search != "" { + arg.SearchText = pgtype.Text{String: dto.Search, Valid: true} + } +} + +func (s *submissionService) GetSubmissionByID(ctx context.Context, id string) (*response.SubmissionResponse, error) { + submissionUUID, err := convert.StringToUUID(id) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid submission ID") + } + + submission, err := s.submissionRepo.GetByID(ctx, submissionUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Submission not found") + } + + return submission.ToResponse(), nil +} + +func (s *submissionService) SearchSubmissions(ctx context.Context, dto *request.SearchSubmissionDto) (*response.PaginatedResponse, error) { + if dto.Page < 1 { + dto.Page = 1 + } + if dto.Limit == 0 { + dto.Limit = 20 + } + offset := (dto.Page - 1) * dto.Limit + + arg := sqlc.SearchSubmissionsParams{ + Limit: int32(dto.Limit), + Offset: int32(offset), + } + + s.fillSearchArgs(&arg, dto) + + var rows []*models.SubmissionEntity + var totalRecords int64 + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + rows, err = s.submissionRepo.Search(gCtx, arg) + return err + }) + + g.Go(func() error { + countArg := sqlc.CountSubmissionsParams{ + UserIds: arg.UserIds, + Statuses: arg.Statuses, + ReviewedBy: arg.ReviewedBy, + CreatedFrom: arg.CreatedFrom, + CreatedTo: arg.CreatedTo, + SearchText: arg.SearchText, + } + var err error + totalRecords, err = s.submissionRepo.Count(gCtx, countArg) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + submissions := models.SubmissionsEntityToResponse(rows) + + return response.BuildPaginatedResponse(submissions, totalRecords, dto.Page, dto.Limit), nil +} + +func (s *submissionService) DeleteSubmission(ctx context.Context, userID string, id string, claims *response.JWTClaims) error { + submissionUUID, err := convert.StringToUUID(id) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid submission ID") + } + + submission, err := s.submissionRepo.GetByID(ctx, submissionUUID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, "Submission not found") + } + + shoudDelete := false + if slices.Contains(claims.Roles, constants.RoleTypeAdmin) || slices.Contains(claims.Roles, constants.RoleTypeMod) { + shoudDelete = true + } + + if submission.UserID == claims.UId && submission.Status == constants.StatusTypePending { + shoudDelete = true + } + + if !shoudDelete { + return fiber.NewError(fiber.StatusForbidden, "You don't have permission to delete this submission") + } + + err = s.submissionRepo.Delete(ctx, submissionUUID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete submission") + } + + return nil +} diff --git a/internal/services/userService.go b/internal/services/userService.go index c26f2d4..5bd1a74 100644 --- a/internal/services/userService.go +++ b/internal/services/userService.go @@ -15,13 +15,13 @@ import ( "github.com/gofiber/fiber/v3" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "golang.org/x/sync/errgroup" ) type UserService interface { //user - GetUserCurrent(ctx context.Context, userId string) (*response.UserResponse, error) UpdateProfile(ctx context.Context, userId string, dto *request.UpdateProfileDto) (*response.UserResponse, error) ChangePassword(ctx context.Context, userId string, dto *request.ChangePasswordDto) error @@ -37,21 +37,33 @@ type userService struct { userRepo repositories.UserRepository roleRepo repositories.RoleRepository c cache.Cache + db *pgxpool.Pool } func NewUserService( userRepo repositories.UserRepository, roleRepo repositories.RoleRepository, c cache.Cache, + db *pgxpool.Pool, + ) UserService { return &userService{ userRepo: userRepo, roleRepo: roleRepo, c: c, + db: db, } } func (u *userService) ChangePassword(ctx context.Context, userId string, dto *request.ChangePasswordDto) error { + tx, err := u.db.Begin(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + defer tx.Rollback(ctx) + + uRepo := u.userRepo.WithTx(tx) + pgID, err := convert.StringToUUID(userId) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) @@ -68,7 +80,6 @@ func (u *userService) ChangePassword(ctx context.Context, userId string, dto *re if dto.OldPassword == "" { return fiber.NewError(fiber.StatusBadRequest, "Old password required") } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(dto.OldPassword)); err != nil { return fiber.NewError(fiber.StatusUnauthorized, "Invalid password!") } @@ -81,7 +92,7 @@ func (u *userService) ChangePassword(ctx context.Context, userId string, dto *re return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = u.userRepo.UpdatePassword(ctx, sqlc.UpdateUserPasswordParams{ + err = uRepo.UpdatePassword(ctx, sqlc.UpdateUserPasswordParams{ ID: pgID, PasswordHash: pgtype.Text{String: string(hashPassword), Valid: true}, }) @@ -89,10 +100,30 @@ func (u *userService) ChangePassword(ctx context.Context, userId string, dto *re return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } + err = uRepo.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ + ID: pgID, + TokenVersion: user.TokenVersion + 1, + }) + if err != nil { + return err + } + + if err := tx.Commit(ctx); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } return nil } func (u *userService) ChangeRoleUser(ctx context.Context, userId string, claims *response.JWTClaims, dto *request.ChangeRoleDto) (*response.UserResponse, error) { + tx, err := u.db.Begin(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + defer tx.Rollback(ctx) + + uRepo := u.userRepo.WithTx(tx) + rRepo := u.roleRepo.WithTx(tx) + userUUID, err := convert.StringToUUID(userId) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) @@ -180,12 +211,12 @@ func (u *userService) ChangeRoleUser(ctx context.Context, userId string, claims user.Roles = append(user.Roles, role.ToRoleSimple()) } - err = u.roleRepo.BulkDeleteRolesFromUser(ctx, userUUID) + err = rRepo.BulkDeleteRolesFromUser(ctx, userUUID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = u.roleRepo.CreateUserRole(ctx, sqlc.CreateUserRoleParams{ + err = rRepo.CreateUserRole(ctx, sqlc.CreateUserRoleParams{ UserID: userUUID, Column2: roleIdList, }) @@ -193,7 +224,7 @@ func (u *userService) ChangeRoleUser(ctx context.Context, userId string, claims return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = u.userRepo.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ + err = uRepo.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ ID: userUUID, TokenVersion: user.TokenVersion + 1, }) @@ -202,6 +233,11 @@ func (u *userService) ChangeRoleUser(ctx context.Context, userId string, claims } user.TokenVersion += 1 + err = tx.Commit(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + mapCache := map[string]any{ fmt.Sprintf("user:email:%s", user.Email): user, fmt.Sprintf("user:id:%s", user.ID): user, @@ -209,7 +245,6 @@ func (u *userService) ChangeRoleUser(ctx context.Context, userId string, claims _ = u.c.MSet(ctx, mapCache, constants.NormalCacheDuration) return user.ToResponse(), nil - } func (u *userService) DeleteUser(ctx context.Context, userId string) error { @@ -264,18 +299,6 @@ func (u *userService) UpdateProfile(ctx context.Context, userId string, dto *req return newUser.ToResponse(), nil } -func (u *userService) GetUserCurrent(ctx context.Context, userId string) (*response.UserResponse, error) { - pgID, err := convert.StringToUUID(userId) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) - } - user, err := u.userRepo.GetByID(ctx, pgID) - if err != nil { - return nil, fiber.NewError(fiber.StatusNotFound, err.Error()) - } - return user.ToResponse(), nil -} - func (u *userService) RestoreUser(ctx context.Context, userId string) (*response.UserResponse, error) { pgID, err := convert.StringToUUID(userId) if err != nil { diff --git a/internal/services/verificationService.go b/internal/services/verificationService.go index bb0215f..d26aaed 100644 --- a/internal/services/verificationService.go +++ b/internal/services/verificationService.go @@ -15,6 +15,7 @@ import ( "github.com/gofiber/fiber/v3" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/sync/errgroup" ) @@ -33,6 +34,7 @@ type verificationService struct { userRepo repositories.UserRepository roleRepo repositories.RoleRepository c cache.Cache + db *pgxpool.Pool } func NewVerificationService( @@ -41,6 +43,7 @@ func NewVerificationService( userRepo repositories.UserRepository, roleRepo repositories.RoleRepository, c cache.Cache, + db *pgxpool.Pool, ) VerificationService { return &verificationService{ verificationRepo: verificationRepo, @@ -48,6 +51,7 @@ func NewVerificationService( userRepo: userRepo, roleRepo: roleRepo, c: c, + db: db, } } @@ -275,6 +279,12 @@ func (v *verificationService) SearchVerification(ctx context.Context, dto *reque } func (v *verificationService) UpdateStatusVerification(ctx context.Context, userId string, verificationId string, dto *request.UpdateVerificationStatusDto) (*response.UserVerificationResponse, error) { + tx, err := v.db.Begin(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + defer tx.Rollback(ctx) + statusType := constants.ParseStatusTypeText(dto.Status) if statusType == constants.StatusTypeUnknown { return nil, fiber.NewError(fiber.StatusInternalServerError, "Unknown status type!") @@ -289,7 +299,7 @@ func (v *verificationService) UpdateStatusVerification(ctx context.Context, user return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - historianRole, err := v.roleRepo.GetByname(ctx, constants.RoleTypeHistorian.String()) + historianRole, err := v.roleRepo.GetByName(ctx, constants.RoleTypeHistorian.String()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } @@ -318,7 +328,11 @@ func (v *verificationService) UpdateStatusVerification(ctx context.Context, user return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = v.verificationRepo.UpdateStatus( + vRepoTx := v.verificationRepo.WithTx(tx) + rRepoTx := v.roleRepo.WithTx(tx) + uRepoTx := v.userRepo.WithTx(tx) + + err = vRepoTx.UpdateStatus( ctx, sqlc.UpdateUserVerificationStatusParams{ ID: verificationUUID, @@ -354,12 +368,12 @@ func (v *verificationService) UpdateStatusVerification(ctx context.Context, user roleIdList = append(roleIdList, roleID) } - err = v.roleRepo.BulkDeleteRolesFromUser(ctx, userVerificationUUID) + err = rRepoTx.BulkDeleteRolesFromUser(ctx, userVerificationUUID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = v.roleRepo.CreateUserRole(ctx, sqlc.CreateUserRoleParams{ + err = rRepoTx.CreateUserRole(ctx, sqlc.CreateUserRoleParams{ UserID: userVerificationUUID, Column2: roleIdList, }) @@ -367,7 +381,7 @@ func (v *verificationService) UpdateStatusVerification(ctx context.Context, user return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - err = v.userRepo.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ + err = uRepoTx.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ ID: userVerificationUUID, TokenVersion: userVerification.TokenVersion + 1, }) @@ -376,11 +390,19 @@ func (v *verificationService) UpdateStatusVerification(ctx context.Context, user } userVerification.TokenVersion += 1 + if err := tx.Commit(ctx); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + mapCache := map[string]any{ fmt.Sprintf("user:email:%s", userVerification.Email): userVerification, fmt.Sprintf("user:id:%s", userVerification.ID): userVerification, } _ = v.c.MSet(ctx, mapCache, constants.NormalCacheDuration) + } else { + if err := tx.Commit(ctx); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } } v.c.PublishTask(ctx, constants.StreamEmailName, constants.TaskTypeNotifyHistorianReview, data) diff --git a/pkg/constants/project_member.go b/pkg/constants/project_member.go new file mode 100644 index 0000000..3361cdb --- /dev/null +++ b/pkg/constants/project_member.go @@ -0,0 +1,64 @@ +package constants + +type ProjectMemberRole int16 + +const ( + ProjectMemberRoleOwner ProjectMemberRole = 1 + ProjectMemberRoleEditor ProjectMemberRole = 2 + ProjectMemberRoleViewer ProjectMemberRole = 3 +) + +func (r ProjectMemberRole) String() string { + switch r { + case ProjectMemberRoleOwner: + return "OWNER" + case ProjectMemberRoleEditor: + return "EDITOR" + case ProjectMemberRoleViewer: + return "VIEWER" + default: + return "UNKNOWN" + } +} + +func (r ProjectMemberRole) Int16() int16 { + return int16(r) +} + +func ParseProjectMemberRole(v int16) ProjectMemberRole { + switch v { + case 1: + return ProjectMemberRoleOwner + case 2: + return ProjectMemberRoleEditor + case 3: + return ProjectMemberRoleViewer + default: + return ProjectMemberRoleViewer + } +} + +func ParseProjectMemberRoleText(v string) ProjectMemberRole { + switch v { + case "OWNER": + return ProjectMemberRoleOwner + case "EDITOR": + return ProjectMemberRoleEditor + case "VIEWER": + return ProjectMemberRoleViewer + default: + return ProjectMemberRoleViewer + } +} + +func (r ProjectMemberRole) CanWrite() bool { + return r == ProjectMemberRoleOwner || r == ProjectMemberRoleEditor +} + +func (r ProjectMemberRole) CanRead() bool { + return r == ProjectMemberRoleOwner || r == ProjectMemberRoleEditor || r == ProjectMemberRoleViewer +} + +func (r ProjectMemberRole) IsOwner() bool { + return r == ProjectMemberRoleOwner +} diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index dbcc18c..c4f92e7 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -1,11 +1,20 @@ package convert import ( + "crypto/md5" + "encoding/json" + "fmt" "time" "github.com/jackc/pgx/v5/pgtype" ) +func GenerateQueryKey(prefix string, params any) string { + b, _ := json.Marshal(params) + hash := fmt.Sprintf("%x", md5.Sum(b)) + return fmt.Sprintf("%s:query:%s", prefix, hash) +} + func UUIDToString(v pgtype.UUID) string { if v.Valid { return v.String() @@ -75,6 +84,16 @@ func PtrToText(s *string) pgtype.Text { } } +func StringToText(s string) pgtype.Text { + if s == "" { + return pgtype.Text{Valid: false} + } + return pgtype.Text{ + String: s, + Valid: true, + } +} + func TextToPtr(v pgtype.Text) *string { if !v.Valid { return nil diff --git a/test.ts b/test.ts deleted file mode 100644 index 725fdcb..0000000 --- a/test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import axios from "axios" -