feat: implement geometry and project management modules with associated controllers, services, and routes

This commit is contained in:
2026-05-24 19:34:46 +07:00
parent ff56ba3d32
commit 2fa420492c
11 changed files with 382 additions and 3 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
})
}

View File

@@ -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",
})
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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{}

View File

@@ -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
}