From 2fa420492c6597f693d85e48bd65f709d44a8a43 Mon Sep 17 00:00:00 2001 From: AzenKain Date: Sun, 24 May 2026 19:34:46 +0700 Subject: [PATCH] feat: implement geometry and project management modules with associated controllers, services, and routes --- cmd/api/server.go | 2 +- db/query/geometries.sql | 11 +++ internal/controllers/geometryController.go | 27 ++++++ internal/controllers/projectController.go | 102 ++++++++++++++++++++ internal/gen/sqlc/geometries.sql.go | 64 ++++++++++++ internal/repositories/geometryRepository.go | 51 ++++++++++ internal/routes/geometryRoute.go | 1 + internal/routes/projectRoute.go | 18 ++++ internal/services/commitService.go | 6 ++ internal/services/geometryService.go | 13 +++ internal/services/projectService.go | 90 ++++++++++++++++- 11 files changed, 382 insertions(+), 3 deletions(-) diff --git a/cmd/api/server.go b/cmd/api/server.go index 10ef21b..965adaa 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -113,7 +113,7 @@ func (s *FiberServer) SetupServer( entityService := services.NewEntityService(entityRepo) geometryService := services.NewGeometryService(geometryRepo) wikiService := services.NewWikiService(wikiRepo) - projectService := services.NewProjectService(projectRepo) + projectService := services.NewProjectService(projectRepo, redis) commitService := services.NewCommitService(poolPg, commitRepo, projectRepo, redis) submissionService := services.NewSubmissionService( submissionRepo, projectRepo, commitRepo, diff --git a/db/query/geometries.sql b/db/query/geometries.sql index afb9f8c..37d7880 100644 --- a/db/query/geometries.sql +++ b/db/query/geometries.sql @@ -194,3 +194,14 @@ JOIN entity_geometries eg ON eg.entity_id = e.id AND eg.geometry_id = pairs.gid JOIN geometries g ON g.id = pairs.gid WHERE e.is_deleted = false AND g.is_deleted = false; + +-- name: GetGeometriesByBoundWith :many +SELECT + id, geo_type, draw_geometry, bound_with, time_start, time_end, project_id, + ST_XMin(bbox)::float8 as min_lng, + ST_YMin(bbox)::float8 as min_lat, + ST_XMax(bbox)::float8 as max_lng, + ST_YMax(bbox)::float8 as max_lat, + is_deleted, created_at, updated_at +FROM geometries +WHERE bound_with = $1 AND is_deleted = false; diff --git a/internal/controllers/geometryController.go b/internal/controllers/geometryController.go index 7093df1..6858422 100644 --- a/internal/controllers/geometryController.go +++ b/internal/controllers/geometryController.go @@ -118,3 +118,30 @@ func (h *GeometryController) SearchGeometriesByEntityName(c fiber.Ctx) error { Data: res, }) } + +// GetGeometriesByBoundWith handles fetching geometries by their bound_with reference. +// @Summary Get geometries by bound_with ID +// @Description Get a list of geometries that are bound to the specified geometry ID +// @Tags Geometries +// @Accept json +// @Produce json +// @Param bound_with path string true "Bound-with Geometry ID" +// @Success 200 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /geometries/bound-with/{bound_with} [get] +func (h *GeometryController) GetGeometriesByBoundWith(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + boundWith := c.Params("bound_with") + res, err := h.service.GetGeometriesByBoundWith(ctx, boundWith) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + 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 3f9ea68..423f692 100644 --- a/internal/controllers/projectController.go +++ b/internal/controllers/projectController.go @@ -456,3 +456,105 @@ func (h *ProjectController) ChangeOwner(c fiber.Ctx) error { Data: res, }) } + +// LockProject godoc +// @Summary Lock a project +// @Description Acquire an exclusive editing lock on a project for 15 minutes +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 409 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/lock [post] +func (h *ProjectController) LockProject(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + uid := c.Locals("uid").(string) + + err := h.service.LockProject(ctx, uid, projectID) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Message: "Project locked successfully", + }) +} + +// UnlockProject godoc +// @Summary Unlock a project +// @Description Release the exclusive editing lock on a project +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/unlock [post] +func (h *ProjectController) UnlockProject(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + uid := c.Locals("uid").(string) + + err := h.service.UnlockProject(ctx, uid, projectID) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Message: "Project unlocked successfully", + }) +} + +// HeartbeatProject godoc +// @Summary Heartbeat to refresh project lock +// @Description Refresh the TTL of the exclusive editing lock on a project for another 15 minutes +// @Tags Projects +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Project ID" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 409 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/{id}/heartbeat [post] +func (h *ProjectController) HeartbeatProject(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + projectID := c.Params("id") + uid := c.Locals("uid").(string) + + err := h.service.HeartbeatProject(ctx, uid, projectID) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Message: "Lock refreshed successfully", + }) +} diff --git a/internal/gen/sqlc/geometries.sql.go b/internal/gen/sqlc/geometries.sql.go index 7fcab41..fadca70 100644 --- a/internal/gen/sqlc/geometries.sql.go +++ b/internal/gen/sqlc/geometries.sql.go @@ -259,6 +259,70 @@ func (q *Queries) GetEntityGeometriesByPairs(ctx context.Context, arg GetEntityG return items, nil } +const getGeometriesByBoundWith = `-- name: GetGeometriesByBoundWith :many +SELECT + id, geo_type, draw_geometry, bound_with, time_start, time_end, project_id, + ST_XMin(bbox)::float8 as min_lng, + ST_YMin(bbox)::float8 as min_lat, + ST_XMax(bbox)::float8 as max_lng, + ST_YMax(bbox)::float8 as max_lat, + is_deleted, created_at, updated_at +FROM geometries +WHERE bound_with = $1 AND is_deleted = false +` + +type GetGeometriesByBoundWithRow struct { + ID pgtype.UUID `json:"id"` + GeoType int16 `json:"geo_type"` + DrawGeometry json.RawMessage `json:"draw_geometry"` + BoundWith pgtype.UUID `json:"bound_with"` + TimeStart pgtype.Int4 `json:"time_start"` + TimeEnd pgtype.Int4 `json:"time_end"` + ProjectID pgtype.UUID `json:"project_id"` + MinLng float64 `json:"min_lng"` + MinLat float64 `json:"min_lat"` + MaxLng float64 `json:"max_lng"` + MaxLat float64 `json:"max_lat"` + IsDeleted bool `json:"is_deleted"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetGeometriesByBoundWith(ctx context.Context, boundWith pgtype.UUID) ([]GetGeometriesByBoundWithRow, error) { + rows, err := q.db.Query(ctx, getGeometriesByBoundWith, boundWith) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetGeometriesByBoundWithRow{} + for rows.Next() { + var i GetGeometriesByBoundWithRow + if err := rows.Scan( + &i.ID, + &i.GeoType, + &i.DrawGeometry, + &i.BoundWith, + &i.TimeStart, + &i.TimeEnd, + &i.ProjectID, + &i.MinLng, + &i.MinLat, + &i.MaxLng, + &i.MaxLat, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getGeometriesByIDs = `-- name: GetGeometriesByIDs :many SELECT id, geo_type, draw_geometry, bound_with, time_start, time_end, project_id, diff --git a/internal/repositories/geometryRepository.go b/internal/repositories/geometryRepository.go index 1da8365..78a2ed4 100644 --- a/internal/repositories/geometryRepository.go +++ b/internal/repositories/geometryRepository.go @@ -29,6 +29,7 @@ type GeometryRepository interface { CreateEntityGeometries(ctx context.Context, params sqlc.CreateEntityGeometriesParams) error BulkDeleteEntityGeometriesByEntityId(ctx context.Context, entityId pgtype.UUID) error GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.GeometryEntity, error) + GetGeometriesByBoundWith(ctx context.Context, boundWith pgtype.UUID) ([]*models.GeometryEntity, error) DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error BulkDeleteEntityGeometriesByGeometryID(ctx context.Context, geometryID pgtype.UUID) error DeleteEntityGeometry(ctx context.Context, entityID pgtype.UUID, geometryID pgtype.UUID) error @@ -357,6 +358,56 @@ func (r *geometryRepository) GetByProjectID(ctx context.Context, projectID pgtyp return geometries, nil } +func (r *geometryRepository) GetGeometriesByBoundWith(ctx context.Context, boundWith pgtype.UUID) ([]*models.GeometryEntity, error) { + cacheKey := fmt.Sprintf("geometry:bound_with:%s", convert.UUIDToString(boundWith)) + var cachedIDs []string + if err := r.c.Get(ctx, cacheKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.GetGeometriesByBoundWith(ctx, boundWith) + if err != nil { + return nil, err + } + + var geometries []*models.GeometryEntity + var ids []string + geometryToCache := make(map[string]any) + + for _, row := range rows { + geometry := &models.GeometryEntity{ + ID: convert.UUIDToString(row.ID), + GeoType: row.GeoType, + DrawGeometry: row.DrawGeometry, + BoundWith: convert.UUIDToStringPtr(row.BoundWith), + TimeStart: convert.Int4ToInt32(row.TimeStart), + TimeEnd: convert.Int4ToInt32(row.TimeEnd), + Bbox: &response.Bbox{ + MinLng: row.MinLng, + MinLat: row.MinLat, + MaxLng: row.MaxLng, + MaxLat: row.MaxLat, + }, + ProjectID: convert.UUIDToString(row.ProjectID), + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + ids = append(ids, geometry.ID) + geometries = append(geometries, geometry) + geometryToCache[fmt.Sprintf("geometry:id:%s", geometry.ID)] = geometry + } + + if len(geometryToCache) > 0 { + _ = r.c.MSet(ctx, geometryToCache, constants.NormalCacheDuration) + } + if len(ids) > 0 { + _ = r.c.Set(ctx, cacheKey, ids, constants.ListCacheDuration) + } + + return geometries, nil +} + func (r *geometryRepository) DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error { err := r.q.DeleteGeometriesByIDs(ctx, ids) if err != nil { diff --git a/internal/routes/geometryRoute.go b/internal/routes/geometryRoute.go index 3741cad..5bbf464 100644 --- a/internal/routes/geometryRoute.go +++ b/internal/routes/geometryRoute.go @@ -10,5 +10,6 @@ func GeometryRoutes(router fiber.Router, geometryController *controllers.Geometr geometry := router.Group("/geometries") geometry.Get("/", geometryController.SearchGeometries) geometry.Get("/entity", geometryController.SearchGeometriesByEntityName) + geometry.Get("/bound-with/:bound_with", geometryController.GetGeometriesByBoundWith) geometry.Get("/:id", geometryController.GetGeometryById) } diff --git a/internal/routes/projectRoute.go b/internal/routes/projectRoute.go index 3b52df6..f62b8c5 100644 --- a/internal/routes/projectRoute.go +++ b/internal/routes/projectRoute.go @@ -66,6 +66,24 @@ func ProjectRoutes( controller.ChangeOwner, ) + route.Post( + "/:id/lock", + middlewares.JwtAccess(userRepo), + controller.LockProject, + ) + + route.Post( + "/:id/unlock", + middlewares.JwtAccess(userRepo), + controller.UnlockProject, + ) + + route.Post( + "/:id/heartbeat", + middlewares.JwtAccess(userRepo), + controller.HeartbeatProject, + ) + route.Get( "/:id", middlewares.JwtAccess(userRepo), diff --git a/internal/services/commitService.go b/internal/services/commitService.go index d00061e..1c18801 100644 --- a/internal/services/commitService.go +++ b/internal/services/commitService.go @@ -52,6 +52,12 @@ func (s *commitService) checkWritePermission(ctx context.Context, userID string, return fiber.NewError(fiber.StatusNotFound, "Project not found") } + lockKey := fmt.Sprintf("project:lock:%s", convert.UUIDToString(projectUUID)) + var lockUser string + if err := s.c.Get(ctx, lockKey, &lockUser); err == nil && lockUser != "" && lockUser != userID { + return fiber.NewError(fiber.StatusConflict, "Cannot commit: Project is locked by another user who is editing") + } + if project.UserID == userID { return nil } diff --git a/internal/services/geometryService.go b/internal/services/geometryService.go index eb75690..6faea51 100644 --- a/internal/services/geometryService.go +++ b/internal/services/geometryService.go @@ -17,6 +17,7 @@ type GeometryService interface { GetGeometryByID(ctx context.Context, id string) (*response.GeometryResponse, *fiber.Error) SearchGeometries(ctx context.Context, req *request.SearchGeometryDto) ([]*response.GeometryResponse, *fiber.Error) SearchGeometriesByEntityName(ctx context.Context, req *request.SearchGeometriesByEntityNameDto) (*response.SearchGeometriesByEntityNameResponse, *fiber.Error) + GetGeometriesByBoundWith(ctx context.Context, boundWith string) ([]*response.GeometryResponse, *fiber.Error) } type geometryService struct { @@ -42,6 +43,18 @@ func (s *geometryService) GetGeometryByID(ctx context.Context, id string) (*resp return geometry.ToResponse(), nil } +func (s *geometryService) GetGeometriesByBoundWith(ctx context.Context, boundWith string) ([]*response.GeometryResponse, *fiber.Error) { + boundWithUUID, err := convert.StringToUUID(boundWith) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid bound_with geometry ID format") + } + geometries, err := s.geometryRepo.GetGeometriesByBoundWith(ctx, boundWithUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch geometries by bound_with") + } + return models.GeometriesEntityToResponse(geometries), nil +} + func (s *geometryService) SearchGeometries(ctx context.Context, req *request.SearchGeometryDto) ([]*response.GeometryResponse, *fiber.Error) { params := sqlc.SearchGeometriesParams{} diff --git a/internal/services/projectService.go b/internal/services/projectService.go index 8954f67..d278e4c 100644 --- a/internal/services/projectService.go +++ b/internal/services/projectService.go @@ -2,6 +2,8 @@ package services import ( "context" + "fmt" + "time" "github.com/gofiber/fiber/v3" "github.com/jackc/pgx/v5/pgtype" @@ -12,6 +14,7 @@ import ( "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" ) @@ -27,15 +30,20 @@ type ProjectService interface { UpdateMemberRole(ctx context.Context, claims *response.JWTClaims, projectID string, memberUserID string, dto *request.UpdateProjectMemberDto) (*response.ProjectResponse, *fiber.Error) RemoveMember(ctx context.Context, claims *response.JWTClaims, projectID string, memberUserID string) *fiber.Error ChangeOwner(ctx context.Context, claims *response.JWTClaims, projectID string, newOwnerID string) (*response.ProjectResponse, *fiber.Error) + LockProject(ctx context.Context, userID string, projectID string) *fiber.Error + UnlockProject(ctx context.Context, userID string, projectID string) *fiber.Error + HeartbeatProject(ctx context.Context, userID string, projectID string) *fiber.Error } type projectService struct { projectRepo repositories.ProjectRepository + c cache.Cache } -func NewProjectService(projectRepo repositories.ProjectRepository) ProjectService { +func NewProjectService(projectRepo repositories.ProjectRepository, c cache.Cache) ProjectService { return &projectService{ projectRepo: projectRepo, + c: c, } } @@ -78,7 +86,16 @@ func (s *projectService) GetProjectByID(ctx context.Context, id string) (*respon return nil, fiber.NewError(fiber.StatusNotFound, "Project not found") } - return project.ToResponse(), nil + res := project.ToResponse() + lockKey := fmt.Sprintf("project:lock:%s", id) + var lockUser string + if err := s.c.Get(ctx, lockKey, &lockUser); err == nil && lockUser != "" { + res.LockedBy = &lockUser + } else { + res.LockedBy = nil + } + + return res, nil } func (s *projectService) GetProjectByUserID(ctx context.Context, userID string, dto *request.GetProjectsByUserDto) ([]*response.ProjectResponse, *fiber.Error) { @@ -419,3 +436,72 @@ func (s *projectService) ChangeOwner(ctx context.Context, claims *response.JWTCl return project.ToResponse(), nil } + +func (s *projectService) LockProject(ctx context.Context, userID string, projectID string) *fiber.Error { + projectUUID, err := convert.StringToUUID(projectID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project ID format") + } + + _, err = s.projectRepo.GetByID(ctx, projectUUID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, "Project not found") + } + + lockKey := fmt.Sprintf("project:lock:%s", projectID) + var lockUser string + err = s.c.Get(ctx, lockKey, &lockUser) + if err == nil && lockUser != "" { + if lockUser != userID { + return fiber.NewError(fiber.StatusConflict, "Project is locked by another user") + } + err = s.c.Set(ctx, lockKey, userID, 3*time.Minute) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh lock") + } + return nil + } + + err = s.c.Set(ctx, lockKey, userID, 3*time.Minute) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to acquire lock") + } + + return nil +} + +func (s *projectService) UnlockProject(ctx context.Context, userID string, projectID string) *fiber.Error { + lockKey := fmt.Sprintf("project:lock:%s", projectID) + var lockUser string + err := s.c.Get(ctx, lockKey, &lockUser) + if err != nil || lockUser == "" { + return nil + } + + if lockUser != userID { + return fiber.NewError(fiber.StatusForbidden, "You cannot unlock a project locked by another user") + } + + _ = s.c.Del(ctx, lockKey) + return nil +} + +func (s *projectService) HeartbeatProject(ctx context.Context, userID string, projectID string) *fiber.Error { + lockKey := fmt.Sprintf("project:lock:%s", projectID) + var lockUser string + err := s.c.Get(ctx, lockKey, &lockUser) + if err != nil || lockUser == "" { + return fiber.NewError(fiber.StatusConflict, "Lock does not exist or has expired") + } + + if lockUser != userID { + return fiber.NewError(fiber.StatusForbidden, "Lock is held by another user") + } + + err = s.c.Set(ctx, lockKey, userID, 3*time.Minute) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh lock") + } + + return nil +}