feat: implement geometry and project management modules with associated controllers, services, and routes
This commit is contained in:
@@ -113,7 +113,7 @@ func (s *FiberServer) SetupServer(
|
|||||||
entityService := services.NewEntityService(entityRepo)
|
entityService := services.NewEntityService(entityRepo)
|
||||||
geometryService := services.NewGeometryService(geometryRepo)
|
geometryService := services.NewGeometryService(geometryRepo)
|
||||||
wikiService := services.NewWikiService(wikiRepo)
|
wikiService := services.NewWikiService(wikiRepo)
|
||||||
projectService := services.NewProjectService(projectRepo)
|
projectService := services.NewProjectService(projectRepo, redis)
|
||||||
commitService := services.NewCommitService(poolPg, commitRepo, projectRepo, redis)
|
commitService := services.NewCommitService(poolPg, commitRepo, projectRepo, redis)
|
||||||
submissionService := services.NewSubmissionService(
|
submissionService := services.NewSubmissionService(
|
||||||
submissionRepo, projectRepo, commitRepo,
|
submissionRepo, projectRepo, commitRepo,
|
||||||
|
|||||||
@@ -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
|
JOIN geometries g ON g.id = pairs.gid
|
||||||
WHERE e.is_deleted = false
|
WHERE e.is_deleted = false
|
||||||
AND g.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;
|
||||||
|
|||||||
@@ -118,3 +118,30 @@ func (h *GeometryController) SearchGeometriesByEntityName(c fiber.Ctx) error {
|
|||||||
Data: res,
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -456,3 +456,105 @@ func (h *ProjectController) ChangeOwner(c fiber.Ctx) error {
|
|||||||
Data: res,
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -259,6 +259,70 @@ func (q *Queries) GetEntityGeometriesByPairs(ctx context.Context, arg GetEntityG
|
|||||||
return items, nil
|
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
|
const getGeometriesByIDs = `-- name: GetGeometriesByIDs :many
|
||||||
SELECT
|
SELECT
|
||||||
id, geo_type, draw_geometry, bound_with, time_start, time_end, project_id,
|
id, geo_type, draw_geometry, bound_with, time_start, time_end, project_id,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type GeometryRepository interface {
|
|||||||
CreateEntityGeometries(ctx context.Context, params sqlc.CreateEntityGeometriesParams) error
|
CreateEntityGeometries(ctx context.Context, params sqlc.CreateEntityGeometriesParams) error
|
||||||
BulkDeleteEntityGeometriesByEntityId(ctx context.Context, entityId pgtype.UUID) error
|
BulkDeleteEntityGeometriesByEntityId(ctx context.Context, entityId pgtype.UUID) error
|
||||||
GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.GeometryEntity, 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
|
DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error
|
||||||
BulkDeleteEntityGeometriesByGeometryID(ctx context.Context, geometryID pgtype.UUID) error
|
BulkDeleteEntityGeometriesByGeometryID(ctx context.Context, geometryID pgtype.UUID) error
|
||||||
DeleteEntityGeometry(ctx context.Context, entityID pgtype.UUID, 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
|
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 {
|
func (r *geometryRepository) DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error {
|
||||||
err := r.q.DeleteGeometriesByIDs(ctx, ids)
|
err := r.q.DeleteGeometriesByIDs(ctx, ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ func GeometryRoutes(router fiber.Router, geometryController *controllers.Geometr
|
|||||||
geometry := router.Group("/geometries")
|
geometry := router.Group("/geometries")
|
||||||
geometry.Get("/", geometryController.SearchGeometries)
|
geometry.Get("/", geometryController.SearchGeometries)
|
||||||
geometry.Get("/entity", geometryController.SearchGeometriesByEntityName)
|
geometry.Get("/entity", geometryController.SearchGeometriesByEntityName)
|
||||||
|
geometry.Get("/bound-with/:bound_with", geometryController.GetGeometriesByBoundWith)
|
||||||
geometry.Get("/:id", geometryController.GetGeometryById)
|
geometry.Get("/:id", geometryController.GetGeometryById)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,24 @@ func ProjectRoutes(
|
|||||||
controller.ChangeOwner,
|
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(
|
route.Get(
|
||||||
"/:id",
|
"/:id",
|
||||||
middlewares.JwtAccess(userRepo),
|
middlewares.JwtAccess(userRepo),
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ func (s *commitService) checkWritePermission(ctx context.Context, userID string,
|
|||||||
return fiber.NewError(fiber.StatusNotFound, "Project not found")
|
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 {
|
if project.UserID == userID {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type GeometryService interface {
|
|||||||
GetGeometryByID(ctx context.Context, id string) (*response.GeometryResponse, *fiber.Error)
|
GetGeometryByID(ctx context.Context, id string) (*response.GeometryResponse, *fiber.Error)
|
||||||
SearchGeometries(ctx context.Context, req *request.SearchGeometryDto) ([]*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)
|
SearchGeometriesByEntityName(ctx context.Context, req *request.SearchGeometriesByEntityNameDto) (*response.SearchGeometriesByEntityNameResponse, *fiber.Error)
|
||||||
|
GetGeometriesByBoundWith(ctx context.Context, boundWith string) ([]*response.GeometryResponse, *fiber.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type geometryService struct {
|
type geometryService struct {
|
||||||
@@ -42,6 +43,18 @@ func (s *geometryService) GetGeometryByID(ctx context.Context, id string) (*resp
|
|||||||
return geometry.ToResponse(), nil
|
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) {
|
func (s *geometryService) SearchGeometries(ctx context.Context, req *request.SearchGeometryDto) ([]*response.GeometryResponse, *fiber.Error) {
|
||||||
params := sqlc.SearchGeometriesParams{}
|
params := sqlc.SearchGeometriesParams{}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"history-api/internal/gen/sqlc"
|
"history-api/internal/gen/sqlc"
|
||||||
"history-api/internal/models"
|
"history-api/internal/models"
|
||||||
"history-api/internal/repositories"
|
"history-api/internal/repositories"
|
||||||
|
"history-api/pkg/cache"
|
||||||
"history-api/pkg/constants"
|
"history-api/pkg/constants"
|
||||||
"history-api/pkg/convert"
|
"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)
|
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
|
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)
|
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 {
|
type projectService struct {
|
||||||
projectRepo repositories.ProjectRepository
|
projectRepo repositories.ProjectRepository
|
||||||
|
c cache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProjectService(projectRepo repositories.ProjectRepository) ProjectService {
|
func NewProjectService(projectRepo repositories.ProjectRepository, c cache.Cache) ProjectService {
|
||||||
return &projectService{
|
return &projectService{
|
||||||
projectRepo: projectRepo,
|
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 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) {
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user