diff --git a/api/agenda/handler.go b/api/agenda/handler.go index b883fab..95bbad7 100644 --- a/api/agenda/handler.go +++ b/api/agenda/handler.go @@ -15,6 +15,18 @@ func ApiHandler(r *gin.RouterGroup) { agendaSvc := service_agenda.NewAgendaService() agendaHandler := &AgendaHandler{agendaSvc} - r.Use(middleware.JWTAuth(), middleware.Permission(10)) - r.POST("/submit", agendaHandler.Submit) + // Lv10+ attendee routes + attendee := r.Group("") + attendee.Use(middleware.JWTAuth(), middleware.Permission(10)) + attendee.POST("/submit", agendaHandler.Submit) + attendee.PATCH("/update", agendaHandler.Update) + attendee.GET("/my-list", agendaHandler.MyList) + attendee.GET("/schedule", agendaHandler.ScheduleGet) + + // Manager routes (Lv30+) + manager := r.Group("") + manager.Use(middleware.JWTAuth(), middleware.Permission(30)) + manager.PATCH("/review", agendaHandler.Review) + manager.PATCH("/schedule", agendaHandler.Schedule) + manager.GET("/list", agendaHandler.List) } diff --git a/api/agenda/list.go b/api/agenda/list.go new file mode 100644 index 0000000..a7c1452 --- /dev/null +++ b/api/agenda/list.go @@ -0,0 +1,106 @@ +package agenda + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_agenda" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// List retrieves all agenda items for an event. Manager only. +// +// @Summary List All Agendas +// @Description Returns all agendas for the specified event, regardless of status. Manager only. +// @Tags Agenda +// @Accept json +// @Produce json +// @Security Bearer +// @Param event_id query string true "Event ID" +// @Success 200 {object} utils.RespStatus{data=[]data.AgendaDoc} +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 403 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /agenda/list [get] +func (self *AgendaHandler) List(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_agenda", + "list", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointAgendaList) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + userId, err := uuid.Parse(userIdOrig.(string)) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 500, errorCode) + return + } + + type ListQuery struct { + EventId string `form:"event_id"` + } + + var query ListQuery + if err := c.ShouldBindQuery(&query); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + eventId, err := uuid.Parse(query.EventId) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("invalid event_id")), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + result := self.svc.List(&service_agenda.AgendaListPayload{ + Context: ctx, + UserId: userId, + Data: &service_agenda.AgendaListData{ + EventId: eventId, + }, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) +} diff --git a/api/agenda/my_list.go b/api/agenda/my_list.go new file mode 100644 index 0000000..45a4087 --- /dev/null +++ b/api/agenda/my_list.go @@ -0,0 +1,105 @@ +package agenda + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_agenda" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// MyList retrieves the current user's own agenda submissions for an event. +// +// @Summary My Agenda List +// @Description Returns the calling user's agenda submissions for the specified event. User must be a joined attendee (Lv10+). +// @Tags Agenda +// @Accept json +// @Produce json +// @Security Bearer +// @Param event_id query string true "Event ID" +// @Success 200 {object} utils.RespStatus{data=[]data.Agenda} +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 403 {object} utils.RespStatus{data=nil} "Not an Attendee" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /agenda/my-list [get] +func (self *AgendaHandler) MyList(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_agenda", + "my_list", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointAgendaMyList) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + userId, err := uuid.Parse(userIdOrig.(string)) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 500, errorCode) + return + } + + type MyListQuery struct { + EventId string `form:"event_id"` + } + + var query MyListQuery + if err := c.ShouldBindQuery(&query); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + eventId, err := uuid.Parse(query.EventId) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("invalid event_id")), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + result := self.svc.MyList(&service_agenda.AgendaMyListPayload{ + Context: ctx, + UserId: userId, + Data: &service_agenda.AgendaMyListData{ + EventId: eventId, + }, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) +} diff --git a/api/agenda/review.go b/api/agenda/review.go new file mode 100644 index 0000000..9118a9c --- /dev/null +++ b/api/agenda/review.go @@ -0,0 +1,110 @@ +package agenda + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_agenda" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Review handles manager approval or rejection of an agenda item. +// +// @Summary Review Agenda +// @Description Manager sets the status of an agenda to approved or rejected. Not allowed after agenda is published. +// @Tags Agenda +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body service_agenda.AgendaReviewData true "Review Data" +// @Success 200 {object} utils.RespStatus{data=nil} +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input / Already Published" +// @Failure 403 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 404 {object} utils.RespStatus{data=nil} "Event or Agenda Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /agenda/review [patch] +func (self *AgendaHandler) Review(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_agenda", + "review", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointAgendaReview) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + userId, err := uuid.Parse(userIdOrig.(string)) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 500, errorCode) + return + } + + data := new(service_agenda.AgendaReviewData) + if err := c.ShouldBindJSON(data); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + if data.AgendaId == uuid.Nil || data.EventId == uuid.Nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("agenda_id and event_id are required")), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + if data.Status != "approved" && data.Status != "rejected" { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("status must be 'approved' or 'rejected'")), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + result := self.svc.Review(&service_agenda.AgendaReviewPayload{ + Context: ctx, + UserId: userId, + Data: data, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) +} diff --git a/api/agenda/schedule.go b/api/agenda/schedule.go new file mode 100644 index 0000000..15caa20 --- /dev/null +++ b/api/agenda/schedule.go @@ -0,0 +1,98 @@ +package agenda + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_agenda" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Schedule handles setting start/end times for an approved agenda item. +// +// @Summary Schedule Agenda +// @Description Manager sets start_time and end_time on an approved agenda item. Available even after publish. +// @Tags Agenda +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body service_agenda.AgendaScheduleData true "Schedule Data" +// @Success 200 {object} utils.RespStatus{data=nil} +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input / Not Approved" +// @Failure 404 {object} utils.RespStatus{data=nil} "Agenda Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /agenda/schedule [patch] +func (self *AgendaHandler) Schedule(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_agenda", + "schedule", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointAgendaSchedule) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + userId, err := uuid.Parse(userIdOrig.(string)) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 500, errorCode) + return + } + + data := new(service_agenda.AgendaScheduleData) + if err := c.ShouldBindJSON(data); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + if data.AgendaId == uuid.Nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("agenda_id is required")), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + result := self.svc.Schedule(&service_agenda.AgendaSchedulePayload{ + Context: ctx, + UserId: userId, + Data: data, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) +} diff --git a/api/agenda/schedule_get.go b/api/agenda/schedule_get.go new file mode 100644 index 0000000..4b7dbb3 --- /dev/null +++ b/api/agenda/schedule_get.go @@ -0,0 +1,80 @@ +package agenda + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_agenda" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ScheduleGet returns the published, scheduled agenda for an event. +// +// @Summary Get Agenda Schedule +// @Description Returns all approved and scheduled agenda items, sorted by start_time ascending. Returns 403 if the agenda has not been published. +// @Tags Agenda +// @Produce json +// @Security Bearer +// @Param event_id query string true "Event ID" +// @Success 200 {object} utils.RespStatus{data=[]data.AgendaDoc} +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 403 {object} utils.RespStatus{data=nil} "Agenda Not Published" +// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /agenda/schedule [get] +func (self *AgendaHandler) ScheduleGet(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_agenda", + "schedule_get", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointAgendaScheduleGet) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + type ScheduleQuery struct { + EventId string `form:"event_id"` + } + + var query ScheduleQuery + if err := c.ShouldBindQuery(&query); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + eventId, err := uuid.Parse(query.EventId) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("invalid event_id")), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + result := self.svc.ScheduleGet(&service_agenda.AgendaScheduleGetPayload{ + Context: ctx, + Data: &service_agenda.AgendaScheduleGetData{ + EventId: eventId, + }, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) +} diff --git a/api/agenda/update.go b/api/agenda/update.go new file mode 100644 index 0000000..8619e27 --- /dev/null +++ b/api/agenda/update.go @@ -0,0 +1,113 @@ +package agenda + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_agenda" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Update handles editing an agenda item's name or description. +// +// @Summary Update Agenda +// @Description Submitter may edit their own pending agendas before the event deadline. Managers may edit any agenda with no restrictions. +// @Tags Agenda +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body service_agenda.AgendaUpdateData true "Agenda Update Data" +// @Success 200 {object} utils.RespStatus{data=nil} +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input / Not Pending / Deadline Passed" +// @Failure 403 {object} utils.RespStatus{data=nil} "Not Submitter" +// @Failure 404 {object} utils.RespStatus{data=nil} "Agenda Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /agenda/update [patch] +func (self *AgendaHandler) Update(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_agenda", + "update", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointAgendaUpdate) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + userId, err := uuid.Parse(userIdOrig.(string)) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 500, errorCode) + return + } + + permissionLevelOrig, ok := c.Get("permission_level") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorPermissionDenied), + exception.WithError(errors.New("Missing PermissionLevel")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + data := new(service_agenda.AgendaUpdateData) + if err := c.ShouldBindJSON(data); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + if data.AgendaId == uuid.Nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("agenda_id is required")), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + data.PermissionLevel = permissionLevelOrig.(uint) + + result := self.svc.Update(&service_agenda.AgendaUpdatePayload{ + Context: ctx, + UserId: userId, + Data: data, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) +} diff --git a/api/event/attendance.go b/api/event/attendance.go index 78aff17..a2a5883 100644 --- a/api/event/attendance.go +++ b/api/event/attendance.go @@ -1,6 +1,7 @@ package event import ( + "errors" "nixcn-cms/internal/exception" "nixcn-cms/service/service_event" "nixcn-cms/tracer" @@ -10,17 +11,25 @@ import ( "github.com/google/uuid" ) -// AttendanceList handles the retrieval of the attendance list for a specific event. +// AttendanceList handles the retrieval of the paginated attendance list for a specific event. // // @Summary Get Attendance List -// @Description Retrieves the list of attendees, including user info and decrypted KYC data for a specified event. +// @Description Retrieves the paginated list of attendees with optional filters. Only accessible by the event owner (Manager). Supports name substring search and KYC status filtering. // @Tags Event // @Produce json // @Security Bearer // @Param event_id query string true "Event UUID" +// @Param name query string false "Substring filter on attendee nickname" +// @Param kyc_status query string false "KYC filter: 'with_kyc' or 'without_kyc'" +// @Param limit query int false "Maximum number of results to return (default 20)" +// @Param offset query int false "Number of results to skip (default 0)" +// @Param sort_by query string false "Sort field: 'checkin_at' (default) or 'id'" +// @Param sort_order query string false "Sort direction: 'asc' or 'desc' (default)" // @Success 200 {object} utils.RespStatus{data=[]service_event.AttendanceListResponse} "Successful retrieval" // @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" // @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 403 {object} utils.RespStatus{data=nil} "Not Event Owner" +// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found" // @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" // @Router /event/attendance [get] func (self *EventHandler) AttendanceList(c *gin.Context) { @@ -34,8 +43,31 @@ func (self *EventHandler) AttendanceList(c *gin.Context) { ctx = exception.ContextWithEndpoint(ctx, exception.EndpointEventAttendanceList) ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) - eventIdStr := c.Query("event_id") + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + userId, err := uuid.Parse(userIdOrig.(string)) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 500, errorCode) + return + } + + eventIdStr := c.Query("event_id") eventId, err := uuid.Parse(eventIdStr) if err != nil { errorCode := exception.New( @@ -44,17 +76,28 @@ func (self *EventHandler) AttendanceList(c *gin.Context) { exception.WithOriginal(exception.CommonErrorInvalidInput), exception.WithError(err), ).Throw(ctx).String() - utils.HttpResponse(c, 400, errorCode) return } + limit := c.Query("limit") + offset := c.Query("offset") + sortBy := c.Query("sort_by") + sortOrder := c.Query("sort_order") + listData := service_event.AttendanceListData{ - EventId: eventId, + EventId: eventId, + Name: c.Query("name"), + KycStatus: c.Query("kyc_status"), + Limit: &limit, + Offset: &offset, + SortBy: &sortBy, + SortOrder: &sortOrder, } result := self.svc.AttendanceList(&service_event.AttendanceListPayload{ Context: ctx, + UserId: userId, Data: &listData, }) @@ -63,5 +106,5 @@ func (self *EventHandler) AttendanceList(c *gin.Context) { return } - utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data) + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) } diff --git a/api/event/create.go b/api/event/create.go new file mode 100644 index 0000000..28ce639 --- /dev/null +++ b/api/event/create.go @@ -0,0 +1,91 @@ +package event + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_event" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" +) + +// Create handles the request to create a new event. +// +// @Summary Create an Event +// @Description Allows a Lv30+ user to create a new event. Users at exactly Lv30 may only create events with type 'party'. Sets type and enable_kyc, which are immutable after creation. +// @Tags Event +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body service_event.EventCreateData true "Event Creation Details" +// @Success 200 {object} utils.RespStatus{data=service_event.EventCreateResponse} "Successfully created the event" +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized" +// @Failure 403 {object} utils.RespStatus{data=nil} "Permission Denied / Type Not Allowed for this level" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error / Database Error" +// @Router /event/create [post] +func (self *EventHandler) Create(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_event", + "create", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointEventCreate) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + permissionLevelOrig, ok := c.Get("permission_level") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorPermissionDenied), + exception.WithError(errors.New("Missing PermissionLevel")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + var createData service_event.EventCreateData + if err := c.ShouldBindJSON(&createData); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + createData.UserId = userIdOrig.(string) + createData.PermissionLevel = permissionLevelOrig.(uint) + + payload := &service_event.EventCreatePayload{ + Context: ctx, + Data: &createData, + } + + result := self.svc.Create(payload) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) +} diff --git a/api/event/delete.go b/api/event/delete.go new file mode 100644 index 0000000..e95f746 --- /dev/null +++ b/api/event/delete.go @@ -0,0 +1,74 @@ +package event + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_event" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" +) + +// Delete removes an event by event_id. +// +// @Summary Delete an Event +// @Description Permanently deletes an event. Requires Lv40+. +// @Tags Event +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body service_event.EventDeleteData true "Event to delete" +// @Success 200 {object} utils.RespStatus{data=nil} "Successfully deleted" +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /event/delete [delete] +func (self *EventHandler) Delete(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_event", + "delete", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointEventDelete) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + _, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + var deleteData service_event.EventDeleteData + if err := c.ShouldBindJSON(&deleteData); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + result := self.svc.Delete(&service_event.EventDeletePayload{ + Context: ctx, + Data: &deleteData, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) +} diff --git a/api/event/handler.go b/api/event/handler.go index c981e74..b2ed381 100644 --- a/api/event/handler.go +++ b/api/event/handler.go @@ -15,13 +15,31 @@ func ApiHandler(r *gin.RouterGroup) { eventSvc := service_event.NewEventService() eventHandler := &EventHandler{eventSvc} - r.Use(middleware.JWTAuth(), middleware.Permission(10)) - r.GET("/info", eventHandler.Info) - r.GET("/checkin", eventHandler.Checkin) - r.GET("/checkin/query", eventHandler.CheckinQuery) - r.POST("/checkin/submit", middleware.Permission(20), eventHandler.CheckinSubmit) - r.POST("/join", eventHandler.Join) - r.GET("/list", eventHandler.List) - r.GET("/attendance", middleware.Permission(40), eventHandler.AttendanceList) - r.GET("/guide", eventHandler.Guide) + // Lv10+ routes + lv10 := r.Group("") + lv10.Use(middleware.JWTAuth(), middleware.Permission(10)) + lv10.GET("/info", eventHandler.Info) + lv10.GET("/checkin", eventHandler.Checkin) + lv10.GET("/checkin/query", eventHandler.CheckinQuery) + lv10.POST("/join", eventHandler.Join) + lv10.GET("/guide", eventHandler.Guide) + + // Lv20+ routes + lv20 := r.Group("") + lv20.Use(middleware.JWTAuth(), middleware.Permission(20)) + lv20.POST("/checkin/submit", eventHandler.CheckinSubmit) + + // Lv30+ routes + lv30 := r.Group("") + lv30.Use(middleware.JWTAuth(), middleware.Permission(30)) + lv30.GET("/list", eventHandler.List) + lv30.POST("/create", eventHandler.Create) + lv30.PATCH("/update", eventHandler.Update) + lv30.GET("/attendance", eventHandler.AttendanceList) + lv30.GET("/stats", eventHandler.Stats) + + // Lv40+ routes + lv40 := r.Group("") + lv40.Use(middleware.JWTAuth(), middleware.Permission(40)) + lv40.DELETE("/delete", eventHandler.Delete) } diff --git a/api/event/list.go b/api/event/list.go index 8d5ee40..6f5098e 100644 --- a/api/event/list.go +++ b/api/event/list.go @@ -11,20 +11,23 @@ import ( "github.com/google/uuid" ) -// List retrieves a paginated list of events from the database. +// List retrieves a paginated, filterable list of events. // // @Summary List Events -// @Description Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency. +// @Description Returns a paginated list of events. Supports filtering by type and sorting. Lv30 users are automatically scoped to events they own. // @Tags Event // @Accept json // @Produce json // @Security Bearer -// @Param limit query int false "Maximum number of events to return (default 20)" -// @Param offset query int false "Number of events to skip" -// @Success 200 {object} utils.RespStatus{data=[]service_event.EventListResponse} "Successful paginated list retrieval" -// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Missing offset or malformed parameters)" -// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized" -// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Database query failed)" +// @Param limit query int false "Maximum number of events to return (default 20)" +// @Param offset query int true "Number of events to skip" +// @Param type query string false "Filter by event type: 'official' or 'party'" +// @Param sort_by query string false "Sort field: 'start_time' (default), 'end_time', 'name'" +// @Param sort_order query string false "Sort direction: 'asc' or 'desc' (default)" +// @Success 200 {object} utils.RespStatus{data=[]service_event.EventListResponse} "Successful paginated list retrieval" +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" // @Router /event/list [get] func (self *EventHandler) List(c *gin.Context) { ctx, span := tracer.StartSpan( @@ -61,44 +64,58 @@ func (self *EventHandler) List(c *gin.Context) { return } + permissionLevelOrig, ok := c.Get("permission_level") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorPermissionDenied), + exception.WithError(errors.New("Missing PermissionLevel")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + type ListQuery struct { - Limit *string `form:"limit"` - Offset *string `form:"offset"` + Limit *string `form:"limit"` + Offset *string `form:"offset"` + Type *string `form:"type"` + SortBy *string `form:"sort_by"` + SortOrder *string `form:"sort_order"` } var query ListQuery if err := c.ShouldBindQuery(&query); err != nil { - // Handle binding error (e.g., syntax errors in query string) errorCode := exception.New( exception.WithStatus(exception.StatusClient), exception.WithType(exception.TypeCommon), exception.WithOriginal(exception.CommonErrorInvalidInput), exception.WithError(err), ).Throw(ctx).String() - utils.HttpResponse(c, 400, errorCode) return } - // Prepare payload for the service layer - eventListPayload := &service_event.EventListPayload{ + result := self.svc.List(&service_event.EventListPayload{ Context: ctx, UserId: userId, Data: &service_event.EventListData{ - Limit: query.Limit, - Offset: query.Offset, + Limit: query.Limit, + Offset: query.Offset, + Type: query.Type, + SortBy: query.SortBy, + SortOrder: query.SortOrder, + PermissionLevel: permissionLevelOrig.(uint), }, - } + }) - // Call the service implementation - result := self.svc.List(eventListPayload) - - // Check if the service returned any exception if result.Common.Exception.Original != exception.CommonSuccess { utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) return } - // Return successful response with event data - utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), gin.H{ + "total": result.Total, + "items": result.Data, + }) } diff --git a/api/event/stats.go b/api/event/stats.go new file mode 100644 index 0000000..71e4930 --- /dev/null +++ b/api/event/stats.go @@ -0,0 +1,78 @@ +package event + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_event" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Stats returns aggregate statistics for an event. +// +// @Summary Get Event Statistics +// @Description Returns join count, checkin count, KYC pass rate, and agenda submission count. Only accessible by the event owner (Manager). +// @Tags Event +// @Produce json +// @Security Bearer +// @Param event_id query string true "Event UUID" +// @Success 200 {object} utils.RespStatus{data=service_event.EventStatsResponse} "Statistics retrieved successfully" +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 403 {object} utils.RespStatus{data=nil} "Not Event Owner" +// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /event/stats [get] +func (self *EventHandler) Stats(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_event", + "stats", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointEventStats) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + userId, err := uuid.Parse(userIdOrig.(string)) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 500, errorCode) + return + } + + result := self.svc.Stats(&service_event.EventStatsPayload{ + Context: ctx, + UserId: userId, + Data: &service_event.EventStatsData{ + EventId: c.Query("event_id"), + }, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) +} diff --git a/api/event/update.go b/api/event/update.go new file mode 100644 index 0000000..d02943e --- /dev/null +++ b/api/event/update.go @@ -0,0 +1,77 @@ +package event + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_event" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" +) + +// Update modifies editable fields of an event owned by the requesting user. +// +// @Summary Update an Event +// @Description Allows the event owner (Manager) to update name, subtitle, description, start_time, end_time, thumbnail, and is_agenda_published. Changes to type or enable_kyc are rejected. is_agenda_published is write-once: it can only be set to true (requires at least one agenda submission) and cannot be reverted. +// @Tags Event +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body service_event.EventUpdateData true "Fields to update (all optional except event_id)" +// @Success 200 {object} utils.RespStatus{data=nil} "Successfully updated" +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input / Immutable Field / Agenda Pre-flight Failed" +// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 403 {object} utils.RespStatus{data=nil} "Not Event Owner" +// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /event/update [patch] +func (self *EventHandler) Update(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_event", + "update", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointEventUpdate) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + userIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + var updateData service_event.EventUpdateData + if err := c.ShouldBindJSON(&updateData); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + updateData.UserId = userIdOrig.(string) + + result := self.svc.Update(&service_event.EventUpdatePayload{ + Context: ctx, + Data: &updateData, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) +} diff --git a/api/handler.go b/api/handler.go index 883fca7..bead84a 100644 --- a/api/handler.go +++ b/api/handler.go @@ -1,9 +1,11 @@ package api import ( + "nixcn-cms/api/agenda" "nixcn-cms/api/auth" "nixcn-cms/api/event" "nixcn-cms/api/kyc" + "nixcn-cms/api/stats" "nixcn-cms/api/user" "github.com/gin-gonic/gin" @@ -14,4 +16,6 @@ func Handler(r *gin.RouterGroup) { user.ApiHandler(r.Group("/user")) event.ApiHandler(r.Group("/event")) kyc.ApiHandler(r.Group("/kyc")) + agenda.ApiHandler(r.Group("/agenda")) + stats.ApiHandler(r.Group("/stats")) } diff --git a/api/stats/global.go b/api/stats/global.go new file mode 100644 index 0000000..2167f6f --- /dev/null +++ b/api/stats/global.go @@ -0,0 +1,44 @@ +package stats + +import ( + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_stats" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" +) + +// Global returns platform-wide statistics. Lv40+ only. +// +// @Summary Global Stats +// @Description Returns total users, user counts per permission_level, and per-event join/checkin counts. +// @Tags Stats +// @Produce json +// @Security Bearer +// @Success 200 {object} utils.RespStatus{data=service_stats.GlobalStatsResponse} +// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /stats/global [get] +func (self *StatsHandler) Global(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_stats", + "global", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointStatsGlobal) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + result := self.svc.Global(&service_stats.GlobalStatsPayload{ + Context: ctx, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) +} diff --git a/api/stats/handler.go b/api/stats/handler.go new file mode 100644 index 0000000..ce2e70c --- /dev/null +++ b/api/stats/handler.go @@ -0,0 +1,22 @@ +package stats + +import ( + "nixcn-cms/middleware" + "nixcn-cms/service/service_stats" + + "github.com/gin-gonic/gin" +) + +type StatsHandler struct { + svc service_stats.StatsService +} + +func ApiHandler(r *gin.RouterGroup) { + statsSvc := service_stats.NewStatsService() + statsHandler := &StatsHandler{statsSvc} + + // Lv40+ routes + lv40 := r.Group("") + lv40.Use(middleware.JWTAuth(), middleware.Permission(40)) + lv40.GET("/global", statsHandler.Global) +} diff --git a/api/user/admin_update.go b/api/user/admin_update.go new file mode 100644 index 0000000..cf3aa9a --- /dev/null +++ b/api/user/admin_update.go @@ -0,0 +1,115 @@ +package user + +import ( + "errors" + "nixcn-cms/internal/exception" + "nixcn-cms/service/service_user" + "nixcn-cms/tracer" + "nixcn-cms/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// AdminUpdate modifies another user's profile. Lv40+ only. +// +// @Summary Admin Update User +// @Description Lv40+ operators may update any user with a strictly lower permission_level. Editable fields: all profile fields plus permission_level (new value must be below operator's own level). +// @Tags User +// @Accept json +// @Produce json +// @Security Bearer +// @Param user_id path string true "Target User ID" +// @Param payload body service_user.UserInfoUpdateData true "Fields to update" +// @Success 200 {object} utils.RespStatus{data=nil} +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 403 {object} utils.RespStatus{data=nil} "Permission Matrix Violation" +// @Failure 404 {object} utils.RespStatus{data=nil} "Target User Not Found" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" +// @Router /user/update/{user_id} [patch] +func (self *UserHandler) AdminUpdate(c *gin.Context) { + ctx, span := tracer.StartSpan( + c.Request.Context(), + "api_user", + "admin_update", + ) + defer span.End() + + ctx = exception.ContextWithEndpoint(ctx, exception.EndpointUserAdminUpdate) + ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) + + operatorIdOrig, ok := c.Get("user_id") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorMissingUserId), + exception.WithError(errors.New("Missing UserId")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + operatorId, err := uuid.Parse(operatorIdOrig.(string)) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 500, errorCode) + return + } + + permissionLevelOrig, ok := c.Get("permission_level") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorPermissionDenied), + exception.WithError(errors.New("Missing PermissionLevel")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return + } + + targetId, err := uuid.Parse(c.Param("user_id")) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("invalid user_id")), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + var data service_user.UserInfoData + if err := c.ShouldBindJSON(&data); err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + + result := self.svc.UpdateInfo(&service_user.UserInfoPayload{ + Context: ctx, + UserId: targetId, + OperatorId: operatorId, + OperatorLevel: permissionLevelOrig.(uint), + Data: &data, + }) + + if result.Common.Exception.Original != exception.CommonSuccess { + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) + return + } + + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) +} diff --git a/api/user/handler.go b/api/user/handler.go index f25c0ed..ccfdac9 100644 --- a/api/user/handler.go +++ b/api/user/handler.go @@ -15,9 +15,16 @@ func ApiHandler(r *gin.RouterGroup) { userSvc := service_user.NewUserService() userHandler := &UserHandler{userSvc} - r.Use(middleware.JWTAuth(), middleware.Permission(5)) - r.GET("/info", userHandler.Info) - r.GET("/info/:user_id", userHandler.Other) - r.PATCH("/update", userHandler.Update) - r.GET("/list", middleware.Permission(40), userHandler.List) + // Lv5+ routes + lv5 := r.Group("") + lv5.Use(middleware.JWTAuth(), middleware.Permission(5)) + lv5.GET("/info", userHandler.Info) + lv5.GET("/info/:user_id", userHandler.Other) + lv5.PATCH("/update", userHandler.Update) + + // Lv40+ routes + lv40 := r.Group("") + lv40.Use(middleware.JWTAuth(), middleware.Permission(40)) + lv40.GET("/list", userHandler.List) + lv40.PATCH("/update/:user_id", userHandler.AdminUpdate) } diff --git a/api/user/list.go b/api/user/list.go index 9c17693..c29c6f3 100644 --- a/api/user/list.go +++ b/api/user/list.go @@ -5,24 +5,28 @@ import ( "nixcn-cms/service/service_user" "nixcn-cms/tracer" "nixcn-cms/utils" + "strconv" "github.com/gin-gonic/gin" ) -// List retrieves a paginated list of users from the search engine. +// List retrieves a paginated, filterable list of users. Lv40+ only. // -// @Summary List Users -// @Description Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance. +// @Summary List Users (Admin) +// @Description Returns a paginated list of users with permission_level included. Supports filtering by permission_level and sorting. // @Tags User // @Accept json // @Produce json // @Security Bearer -// @Param limit query string false "Maximum number of users to return (default 0)" -// @Param offset query string true "Number of users to skip" -// @Success 200 {object} utils.RespStatus{data=[]service_user.UserListResponse} "Successful paginated list retrieval" -// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized" -// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Format Error)" -// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Search Engine or Missing Offset)" +// @Param limit query string false "Maximum number of users to return (default 20)" +// @Param offset query string true "Number of users to skip" +// @Param sort_by query string false "Sort field: 'id' (default) | 'permission_level'" +// @Param sort_order query string false "Sort direction: 'asc' (default) | 'desc'" +// @Param permission_level query int false "Filter by exact permission level" +// @Success 200 {object} utils.RespStatus{data=[]service_user.UserListResponse} "Successful paginated list retrieval" +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" // @Router /user/list [get] func (self *UserHandler) List(c *gin.Context) { ctx, span := tracer.StartSpan( @@ -36,8 +40,11 @@ func (self *UserHandler) List(c *gin.Context) { ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) type ListQuery struct { - Limit *string `form:"limit"` - Offset *string `form:"offset"` + Limit *string `form:"limit"` + Offset *string `form:"offset"` + SortBy *string `form:"sort_by"` + SortOrder *string `form:"sort_order"` + PermissionLevel *string `form:"permission_level"` } var query ListQuery @@ -48,23 +55,43 @@ func (self *UserHandler) List(c *gin.Context) { exception.WithOriginal(exception.CommonErrorInvalidInput), exception.WithError(err), ).Throw(ctx).String() - utils.HttpResponse(c, 400, errorCode) return } - userListPayload := &service_user.UserListPayload{ - Context: ctx, - Limit: query.Limit, - Offset: query.Offset, + var permLevel *uint + if query.PermissionLevel != nil && *query.PermissionLevel != "" { + v, err := strconv.ParseUint(*query.PermissionLevel, 10, 64) + if err != nil { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx).String() + utils.HttpResponse(c, 400, errorCode) + return + } + u := uint(v) + permLevel = &u } - result := self.svc.List(userListPayload) + result := self.svc.List(&service_user.UserListPayload{ + Context: ctx, + Limit: query.Limit, + Offset: query.Offset, + SortBy: query.SortBy, + SortOrder: query.SortOrder, + PermissionLevel: permLevel, + }) if result.Common.Exception.Original != exception.CommonSuccess { utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) return } - utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), gin.H{ + "total": result.Total, + "items": result.Data, + }) } diff --git a/api/user/update.go b/api/user/update.go index d4bb209..4927751 100644 --- a/api/user/update.go +++ b/api/user/update.go @@ -22,9 +22,9 @@ import ( // @Security Bearer // @Param payload body service_user.UserInfoUpdateData true "Updated User Profile Data" // @Success 200 {object} utils.RespStatus{data=nil} "Successful profile update" -// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Validation Failed)" -// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized" -// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Database Error / UUID Parse Failed)" +// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input" +// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized" +// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error" // @Router /user/update [patch] func (self *UserHandler) Update(c *gin.Context) { ctx, span := tracer.StartSpan( @@ -61,13 +61,26 @@ func (self *UserHandler) Update(c *gin.Context) { return } - userInfoPayload := &service_user.UserInfoPayload{ - Context: ctx, - UserId: userId, + permissionLevelOrig, ok := c.Get("permission_level") + if !ok { + errorCode := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorPermissionDenied), + exception.WithError(errors.New("Missing PermissionLevel")), + ).Throw(ctx).String() + utils.HttpResponse(c, 403, errorCode) + return } - err = c.ShouldBindJSON(&userInfoPayload.Data) - if err != nil { + payload := &service_user.UserInfoPayload{ + Context: ctx, + UserId: userId, + OperatorId: userId, + OperatorLevel: permissionLevelOrig.(uint), + } + + if err := c.ShouldBindJSON(&payload.Data); err != nil { errorCode := exception.New( exception.WithStatus(exception.StatusUser), exception.WithType(exception.TypeCommon), @@ -78,12 +91,12 @@ func (self *UserHandler) Update(c *gin.Context) { return } - result := self.svc.UpdateInfo(userInfoPayload) + result := self.svc.UpdateInfo(payload) if result.Common.Exception.Original != exception.CommonSuccess { utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) return } - utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data) + utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String()) } diff --git a/cmd/gen_exception/definitions/endpoint.yaml b/cmd/gen_exception/definitions/endpoint.yaml index 3095341..707ade7 100644 --- a/cmd/gen_exception/definitions/endpoint.yaml +++ b/cmd/gen_exception/definitions/endpoint.yaml @@ -15,14 +15,27 @@ endpoint: join: "206" attendance_list: "207" attendance_guide: "208" + create: "209" + update: "210" + delete: "211" + stats: "212" user: info: "301" update: "302" list: "303" full: "304" create: "305" + admin_update: "306" + stats: + global: "601" kyc: session: "401" query: "402" agenda: submit: "501" + update: "502" + review: "503" + schedule: "504" + list: "505" + my_list: "506" + schedule_get: "507" diff --git a/cmd/gen_exception/definitions/service.yaml b/cmd/gen_exception/definitions/service.yaml index b7336a9..81a2e9f 100644 --- a/cmd/gen_exception/definitions/service.yaml +++ b/cmd/gen_exception/definitions/service.yaml @@ -10,6 +10,9 @@ service: get_info: "201" list: "202" update_info: "203" + admin_update: "204" + stats: + global: "601" event: attendance_list: "301" checkin: "302" @@ -17,8 +20,18 @@ service: get_event_info: "304" join: "305" list: "306" + create: "307" + update: "308" + delete: "309" + stats: "310" kyc: query: "401" session: "402" agenda: submit: "501" + update: "502" + review: "503" + schedule: "504" + list: "505" + my_list: "506" + schedule_get: "507" diff --git a/cmd/gen_exception/definitions/specific.yaml b/cmd/gen_exception/definitions/specific.yaml index ce7d443..a0600b1 100644 --- a/cmd/gen_exception/definitions/specific.yaml +++ b/cmd/gen_exception/definitions/specific.yaml @@ -25,6 +25,9 @@ auth: user: list: database_failed: "00001" + update: + permission_matrix_violated: "00001" + permission_level_too_high: "00002" event: info: not_found: "00001" @@ -37,6 +40,33 @@ event: limit_exceeded: "00002" attendance: list_error: "00001" + create: + type_not_allowed: "00001" + update: + immutable_field: "00001" + not_owner: "00002" + agenda_already_published: "00003" + agenda_preflight_failed: "00004" + delete: + not_found: "00001" + not_owner: "00002" + stats: + not_owner: "00001" +agenda: + submit: + event_started: "00001" + event_published: "00002" + pending_limit_reached: "00003" + update: + not_pending: "00001" + not_submitter: "00002" + deadline_passed: "00003" + review: + event_published: "00001" + schedule: + not_approved: "00001" + schedule_get: + not_published: "00001" kyc: session: failed: "00001" diff --git a/data/agenda.go b/data/agenda.go index 41f3e00..1d400ed 100644 --- a/data/agenda.go +++ b/data/agenda.go @@ -12,14 +12,24 @@ type Agenda struct { Id uint `json:"id" gorm:"primarykey;autoIncrement"` UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"` AgendaId uuid.UUID `json:"agenda_id" gorm:"type:uuid;uniqueIndex;not null"` - AttendanceId uuid.UUID `json:"attendance_id" gorm:"type:uuid;uniqueIndex;not null"` + AttendanceId uuid.UUID `json:"attendance_id" gorm:"type:uuid;index;not null"` Name string `json:"name" gorm:"type:varchar(255);index;not null"` - Description string `json:"description" gorm:"type:text;index;not null"` // base64 encoded markdown - IsApproved bool `json:"is_approved" gorm:"type:boolean;not null;default:false"` + Description string `json:"description" gorm:"type:text;not null"` // base64 encoded markdown + Status string `json:"status" gorm:"type:varchar(32);index;not null;default:pending"` // pending | approved | rejected StartTime time.Time `json:"start_time" gorm:"index"` EndTime time.Time `json:"end_time" gorm:"index"` } +type AgendaDoc struct { + AgendaId uuid.UUID `json:"agenda_id"` + AttendanceId uuid.UUID `json:"attendance_id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} + func (self *Agenda) SetAttendanceId(id uuid.UUID) *Agenda { self.AttendanceId = id return self @@ -35,8 +45,8 @@ func (self *Agenda) SetDescription(desc string) *Agenda { return self } -func (self *Agenda) SetIsApproved(approved bool) *Agenda { - self.IsApproved = approved +func (self *Agenda) SetStatus(status string) *Agenda { + self.Status = status return self } @@ -69,6 +79,46 @@ func (self *Agenda) GetListByAttendanceId(ctx context.Context, attendanceId uuid return &result, err } +func (self *Agenda) GetListByEventId(ctx context.Context, eventId uuid.UUID) (*[]AgendaDoc, error) { + var result []AgendaDoc + + err := Database.WithContext(ctx). + Model(&Agenda{}). + Select("agendas.agenda_id, agendas.attendance_id, agendas.name, agendas.description, agendas.status, agendas.start_time, agendas.end_time"). + Joins("JOIN attendances ON attendances.attendance_id = agendas.attendance_id"). + Where("attendances.event_id = ?", eventId). + Order("agendas.id ASC"). + Scan(&result).Error + + if err != nil { + return nil, err + } + + return &result, nil +} + +func (self *Agenda) GetScheduledByEventId(ctx context.Context, eventId uuid.UUID) (*[]AgendaDoc, error) { + var result []AgendaDoc + + zero := time.Time{} + + err := Database.WithContext(ctx). + Model(&Agenda{}). + Select("agendas.agenda_id, agendas.attendance_id, agendas.name, agendas.description, agendas.status, agendas.start_time, agendas.end_time"). + Joins("JOIN attendances ON attendances.attendance_id = agendas.attendance_id"). + Where("attendances.event_id = ?", eventId). + Where("agendas.status = ?", "approved"). + Where("agendas.start_time > ? AND agendas.end_time > ?", zero, zero). + Order("agendas.start_time ASC"). + Scan(&result).Error + + if err != nil { + return nil, err + } + + return &result, nil +} + func (self *Agenda) Create(ctx context.Context) error { self.UUID = uuid.New() self.AgendaId = uuid.New() @@ -114,6 +164,48 @@ func (self *Agenda) Update(ctx context.Context, agendaId uuid.UUID) (*Agenda, er return &agenda, nil } +func (self *Agenda) UpdateFieldsByAgendaId(ctx context.Context, agendaId uuid.UUID, updates map[string]any) error { + return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&Agenda{}). + Where("agenda_id = ?", agendaId). + Updates(updates).Error; err != nil { + return err + } + return tx.Where("agenda_id = ?", agendaId).First(self).Error + }) +} + +func (self *Agenda) CountByEventId(ctx context.Context, eventId uuid.UUID) (int64, error) { + var count int64 + + err := Database.WithContext(ctx). + Model(&Agenda{}). + Joins("JOIN attendances ON attendances.attendance_id = agendas.attendance_id"). + Where("attendances.event_id = ?", eventId). + Count(&count).Error + + if err != nil { + return 0, err + } + + return count, nil +} + +func (self *Agenda) CountPendingByAttendanceId(ctx context.Context, attendanceId uuid.UUID) (int64, error) { + var count int64 + + err := Database.WithContext(ctx). + Model(&Agenda{}). + Where("attendance_id = ? AND status = ?", attendanceId, "pending"). + Count(&count).Error + + if err != nil { + return 0, err + } + + return count, nil +} + func (self *Agenda) Delete(ctx context.Context, agendaId uuid.UUID) error { return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Where("agenda_id = ?", agendaId).Delete(&Agenda{}) diff --git a/data/attendance.go b/data/attendance.go index 424886d..6197f91 100644 --- a/data/attendance.go +++ b/data/attendance.go @@ -58,6 +58,23 @@ func (self *Attendance) SetState(s string) *Attendance { return self } +func (self *Attendance) GetAttendanceByAttendanceId(ctx context.Context, attendanceId uuid.UUID) (*Attendance, error) { + var attendance Attendance + + err := Database.WithContext(ctx). + Where("attendance_id = ?", attendanceId). + First(&attendance).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + + return &attendance, nil +} + func (self *Attendance) GetAttendance(ctx context.Context, userId, eventId uuid.UUID) (*Attendance, error) { var attendance Attendance @@ -211,6 +228,78 @@ func (self *Attendance) CountUsersByEventID(ctx context.Context, eventID uuid.UU return count, nil } +type AttendanceListFilter struct { + EventId uuid.UUID + Name string // substring match on users.nickname + KycStatus string // "with_kyc" | "without_kyc" | "" + SortBy string // "checkin_at" | "id" + SortOrder string // "asc" | "desc" + Limit int + Offset int +} + +func (self *Attendance) GetAttendanceListFiltered(ctx context.Context, filter AttendanceListFilter) (*[]Attendance, int64, error) { + var results []Attendance + var total int64 + + base := Database.WithContext(ctx). + Table("attendances"). + Joins("JOIN users ON users.user_id = attendances.user_id"). + Where("attendances.event_id = ?", filter.EventId) + + if filter.Name != "" { + base = base.Where("users.nickname LIKE ?", "%"+filter.Name+"%") + } + + switch filter.KycStatus { + case "with_kyc": + base = base.Where("attendances.kyc_id != ?", uuid.Nil) + case "without_kyc": + base = base.Where("attendances.kyc_id = ?", uuid.Nil) + } + + if err := base.Count(&total).Error; err != nil { + return nil, 0, err + } + + sortField := "attendances.checkin_at" + if filter.SortBy == "id" { + sortField = "attendances.id" + } + sortOrder := "DESC" + if filter.SortOrder == "asc" { + sortOrder = "ASC" + } + + err := base. + Select("attendances.*"). + Order(sortField + " " + sortOrder). + Limit(filter.Limit). + Offset(filter.Offset). + Find(&results).Error + + if err != nil { + return nil, 0, err + } + + return &results, total, nil +} + +func (self *Attendance) CountWithKycByEventID(ctx context.Context, eventID uuid.UUID) (int64, error) { + var count int64 + + err := Database.WithContext(ctx). + Model(&Attendance{}). + Where("event_id = ? AND kyc_id != ?", eventID, uuid.Nil). + Count(&count).Error + + if err != nil { + return 0, err + } + + return count, nil +} + func (self *Attendance) CountCheckedInUsersByEventID(ctx context.Context, eventID uuid.UUID) (int64, error) { var count int64 diff --git a/data/event.go b/data/event.go index 22573dd..8dba5f6 100644 --- a/data/event.go +++ b/data/event.go @@ -9,33 +9,45 @@ import ( ) type Event struct { - Id uint `json:"id" gorm:"primarykey;autoincrement"` - UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"` - EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"` - Name string `json:"name" gorm:"type:varchar(255);index;not null"` - Type string `json:"type" gorm:"type:varchar(255);index;not null"` // official | party - Subtitle string `json:"subtitle" gorm:"type:text;not null;default:an amazing event"` - Description string `json:"description" gorm:"type:text"` // base64 markdown - AttendanceGuide string `json:"attendance_guide" gorm:"type:text"` // base64 markdown - StartTime time.Time `json:"start_time" gorm:"index;not null"` - EndTime time.Time `json:"end_time" gorm:"index;not null"` - Thumbnail string `json:"thumbnail" gorm:"type:varchar(255)"` - Owner uuid.UUID `json:"owner" gorm:"type:uuid;index;not null"` - EnableKYC bool `json:"enable_kyc" gorm:"not null"` - Quota int64 `json:"quota" gorm:"not null"` - Limit int64 `json:"limit" gorm:"not null"` + Id uint `json:"id" gorm:"primarykey;autoincrement"` + UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"` + EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"` + Name string `json:"name" gorm:"type:varchar(255);index;not null"` + Type string `json:"type" gorm:"type:varchar(255);index;not null"` // official | party + Subtitle string `json:"subtitle" gorm:"type:text;not null;default:an amazing event"` + Description string `json:"description" gorm:"type:text"` // base64 markdown + AttendanceGuide string `json:"attendance_guide" gorm:"type:text"` // base64 markdown + StartTime time.Time `json:"start_time" gorm:"index;not null"` + EndTime time.Time `json:"end_time" gorm:"index;not null"` + Thumbnail string `json:"thumbnail" gorm:"type:varchar(255)"` + Owner uuid.UUID `json:"owner" gorm:"type:uuid;index;not null"` + EnableKYC bool `json:"enable_kyc" gorm:"not null"` + IsAgendaPublished bool `json:"is_agenda_published" gorm:"not null;default:false"` + Quota int64 `json:"quota" gorm:"not null"` + Limit int64 `json:"limit" gorm:"not null"` } type EventIndexDoc struct { - EventId string `json:"event_id" validate:"required"` - Name string `json:"name" validate:"required"` - Type string `json:"type" validate:"required"` - Subtitle string `json:"subtitle" validate:"required"` - Description string `json:"description"` - StartTime time.Time `json:"start_time" validate:"required"` - EndTime time.Time `json:"end_time" validate:"required"` - Thumbnail string `json:"thumbnail"` - EnableKYC bool `json:"enable_kyc" validate:"required"` + EventId string `json:"event_id" validate:"required"` + Name string `json:"name" validate:"required"` + Type string `json:"type" validate:"required"` + Subtitle string `json:"subtitle" validate:"required"` + Description string `json:"description"` + StartTime time.Time `json:"start_time" validate:"required"` + EndTime time.Time `json:"end_time" validate:"required"` + Thumbnail string `json:"thumbnail"` + EnableKYC bool `json:"enable_kyc" validate:"required"` + IsAgendaPublished bool `json:"is_agenda_published"` + Owner string `json:"owner"` +} + +type EventListOptions struct { + TypeFilter string + OwnerId *uuid.UUID + SortBy string + SortOrder string + Limit int64 + Offset int64 } func (self *Event) GetEventById(ctx context.Context, eventId uuid.UUID) (*Event, error) { @@ -99,7 +111,7 @@ func (self *Event) FastListEvents(ctx context.Context, limit, offset int64) (*[] err := Database.WithContext(ctx). Model(&Event{}). - Select("event_id", "name", "type", "subtitle", "description", "start_time", "end_time", "thumbnail", "enable_kyc"). + Select("event_id", "name", "type", "subtitle", "description", "start_time", "end_time", "thumbnail", "enable_kyc", "is_agenda_published", "owner"). Limit(int(limit)). Offset(int(offset)). Scan(&results).Error @@ -111,6 +123,74 @@ func (self *Event) FastListEvents(ctx context.Context, limit, offset int64) (*[] return &results, nil } +func (self *Event) ListEventsWithOptions(ctx context.Context, opts EventListOptions) (*[]EventIndexDoc, int64, error) { + var results []EventIndexDoc + var total int64 + + selectCols := "event_id, name, type, subtitle, description, start_time, end_time, thumbnail, enable_kyc, is_agenda_published, owner" + + baseQuery := Database.WithContext(ctx).Model(&Event{}) + + if opts.TypeFilter != "" { + baseQuery = baseQuery.Where("type = ?", opts.TypeFilter) + } + if opts.OwnerId != nil { + baseQuery = baseQuery.Where("owner = ?", opts.OwnerId) + } + + if err := baseQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + sortField := "start_time" + switch opts.SortBy { + case "end_time", "name": + sortField = opts.SortBy + } + + sortOrder := "DESC" + if opts.SortOrder == "asc" { + sortOrder = "ASC" + } + + err := baseQuery. + Select(selectCols). + Order(sortField + " " + sortOrder). + Limit(int(opts.Limit)). + Offset(int(opts.Offset)). + Scan(&results).Error + + if err != nil { + return nil, 0, err + } + + return &results, total, nil +} + +func (self *Event) UpdateEventFieldsById(ctx context.Context, eventId uuid.UUID, updates map[string]any) error { + return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&Event{}). + Where("event_id = ?", eventId). + Updates(updates).Error; err != nil { + return err + } + return tx.Where("event_id = ?", eventId).First(self).Error + }) +} + +func (self *Event) DeleteEventById(ctx context.Context, eventId uuid.UUID) error { + return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Where("event_id = ?", eventId).Delete(&Event{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + func (self *Event) GetEventsByUserId(ctx context.Context, userId uuid.UUID, limit, offset int64) (*[]EventIndexDoc, error) { var results []EventIndexDoc diff --git a/data/stats.go b/data/stats.go new file mode 100644 index 0000000..29e36ca --- /dev/null +++ b/data/stats.go @@ -0,0 +1,70 @@ +package data + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +type PermissionLevelCount struct { + PermissionLevel uint `json:"permission_level"` + Count int64 `json:"count"` +} + +type EventStatDoc struct { + EventId uuid.UUID `json:"event_id"` + Name string `json:"name"` + JoinCount int64 `json:"join_count"` + CheckinCount int64 `json:"checkin_count"` +} + +type GlobalStats struct{} + +func (self *GlobalStats) TotalUsers(ctx context.Context) (int64, error) { + var count int64 + err := Database.WithContext(ctx).Model(&User{}).Count(&count).Error + return count, err +} + +func (self *GlobalStats) UsersPerPermissionLevel(ctx context.Context) (*[]PermissionLevelCount, error) { + var results []PermissionLevelCount + + err := Database.WithContext(ctx). + Model(&User{}). + Select("permission_level, COUNT(*) as count"). + Group("permission_level"). + Order("permission_level ASC"). + Scan(&results).Error + + if err != nil { + return nil, err + } + + return &results, nil +} + +func (self *GlobalStats) EventJoinCheckinCounts(ctx context.Context) (*[]EventStatDoc, error) { + var results []EventStatDoc + + zero := time.Time{} + + err := Database.WithContext(ctx). + Table("events"). + Select(` + events.event_id, + events.name, + COUNT(attendances.id) AS join_count, + SUM(CASE WHEN attendances.checkin_at > ? THEN 1 ELSE 0 END) AS checkin_count + `, zero). + Joins("LEFT JOIN attendances ON attendances.event_id = events.event_id"). + Group("events.event_id, events.name"). + Order("events.id ASC"). + Scan(&results).Error + + if err != nil { + return nil, err + } + + return &results, nil +} diff --git a/data/user.go b/data/user.go index 09ad606..3c6f8f0 100644 --- a/data/user.go +++ b/data/user.go @@ -31,6 +31,24 @@ type UserIndexDoc struct { Avatar string `json:"avatar"` } +type UserAdminDoc struct { + UserId string `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Subtitle string `json:"subtitle"` + Avatar string `json:"avatar"` + PermissionLevel uint `json:"permission_level"` +} + +type UserListOptions struct { + PermissionLevel *uint + SortBy string // "id" | "permission_level" + SortOrder string // "asc" | "desc" + Limit int + Offset int +} + func (self *User) SetEmail(s string) *User { self.Email = s return self @@ -151,3 +169,41 @@ func (self *User) FastListUsers(ctx context.Context, limit, offset *int) (*[]Use return &results, nil } + +func (self *User) ListUsersFiltered(ctx context.Context, opts UserListOptions) (*[]UserAdminDoc, int64, error) { + var results []UserAdminDoc + var total int64 + + base := Database.WithContext(ctx).Model(&User{}) + + if opts.PermissionLevel != nil { + base = base.Where("permission_level = ?", *opts.PermissionLevel) + } + + if err := base.Count(&total).Error; err != nil { + return nil, 0, err + } + + sortField := "id" + if opts.SortBy == "permission_level" { + sortField = "permission_level" + } + + sortOrder := "ASC" + if opts.SortOrder == "desc" { + sortOrder = "DESC" + } + + err := base. + Select("user_id", "email", "username", "nickname", "subtitle", "avatar", "permission_level"). + Order(sortField + " " + sortOrder). + Limit(opts.Limit). + Offset(opts.Offset). + Scan(&results).Error + + if err != nil { + return nil, 0, err + } + + return &results, total, nil +} diff --git a/service/service_agenda/list.go b/service/service_agenda/list.go new file mode 100644 index 0000000..7cf8abe --- /dev/null +++ b/service/service_agenda/list.go @@ -0,0 +1,94 @@ +package service_agenda + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + + "github.com/google/uuid" +) + +type AgendaListData struct { + EventId uuid.UUID `json:"event_id" form:"event_id"` +} + +type AgendaListPayload struct { + Context context.Context + UserId uuid.UUID + Data *AgendaListData +} + +type AgendaListResult struct { + Common shared.CommonResult + Data *[]data.AgendaDoc +} + +func (self *AgendaServiceImpl) List(payload *AgendaListPayload) (result *AgendaListResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_agenda", + "list", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceAgendaList) + + eventData, err := new(data.Event).GetEventById(ctx, payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaListResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if eventData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("event not found")), + ).Throw(ctx) + + result = &AgendaListResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + agendas, err := new(data.Agenda).GetListByEventId(ctx, payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaListResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &AgendaListResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + Data: agendas, + } + return +} diff --git a/service/service_agenda/my_list.go b/service/service_agenda/my_list.go new file mode 100644 index 0000000..a01be8b --- /dev/null +++ b/service/service_agenda/my_list.go @@ -0,0 +1,94 @@ +package service_agenda + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + + "github.com/google/uuid" +) + +type AgendaMyListData struct { + EventId uuid.UUID `json:"event_id" form:"event_id"` +} + +type AgendaMyListPayload struct { + Context context.Context + UserId uuid.UUID + Data *AgendaMyListData +} + +type AgendaMyListResult struct { + Common shared.CommonResult + Data *[]data.Agenda +} + +func (self *AgendaServiceImpl) MyList(payload *AgendaMyListPayload) (result *AgendaMyListResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_agenda", + "my_list", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceAgendaMyList) + + attendanceData, err := new(data.Attendance).GetAttendance(ctx, payload.UserId, payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaMyListResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if attendanceData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorPermissionDenied), + exception.WithError(errors.New("user is not an attendee of this event")), + ).Throw(ctx) + + result = &AgendaMyListResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + + agendas, err := new(data.Agenda).GetListByAttendanceId(ctx, attendanceData.AttendanceId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaMyListResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &AgendaMyListResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + Data: agendas, + } + return +} diff --git a/service/service_agenda/review.go b/service/service_agenda/review.go new file mode 100644 index 0000000..e2f9f02 --- /dev/null +++ b/service/service_agenda/review.go @@ -0,0 +1,139 @@ +package service_agenda + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + + "github.com/google/uuid" +) + +type AgendaReviewData struct { + AgendaId uuid.UUID `json:"agenda_id"` + EventId uuid.UUID `json:"event_id"` + Status string `json:"status"` // approved | rejected +} + +type AgendaReviewPayload struct { + Context context.Context + UserId uuid.UUID + Data *AgendaReviewData +} + +type AgendaReviewResult struct { + Common shared.CommonResult +} + +func (self *AgendaServiceImpl) Review(payload *AgendaReviewPayload) (result *AgendaReviewResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_agenda", + "review", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceAgendaReview) + + eventData, err := new(data.Event).GetEventById(ctx, payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaReviewResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if eventData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("event not found")), + ).Throw(ctx) + + result = &AgendaReviewResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + if eventData.IsAgendaPublished { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaReviewEventPublished), + exception.WithError(errors.New("cannot review agendas after agenda has been published")), + ).Throw(ctx) + + result = &AgendaReviewResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + agendaData, err := new(data.Agenda).GetByAgendaId(ctx, payload.Data.AgendaId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaReviewResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if agendaData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("agenda not found")), + ).Throw(ctx) + + result = &AgendaReviewResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + holder := new(data.Agenda) + if err := holder.UpdateFieldsByAgendaId(ctx, payload.Data.AgendaId, map[string]any{ + "status": payload.Data.Status, + }); err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaReviewResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &AgendaReviewResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + } + return +} diff --git a/service/service_agenda/schedule.go b/service/service_agenda/schedule.go new file mode 100644 index 0000000..d8dae03 --- /dev/null +++ b/service/service_agenda/schedule.go @@ -0,0 +1,126 @@ +package service_agenda + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + "time" + + "github.com/google/uuid" +) + +type AgendaScheduleData struct { + AgendaId uuid.UUID `json:"agenda_id"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} + +type AgendaSchedulePayload struct { + Context context.Context + UserId uuid.UUID + Data *AgendaScheduleData +} + +type AgendaScheduleResult struct { + Common shared.CommonResult +} + +func (self *AgendaServiceImpl) Schedule(payload *AgendaSchedulePayload) (result *AgendaScheduleResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_agenda", + "schedule", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceAgendaSchedule) + + if !payload.Data.StartTime.Before(payload.Data.EndTime) { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("start_time must be before end_time")), + ).Throw(ctx) + + result = &AgendaScheduleResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + agendaData, err := new(data.Agenda).GetByAgendaId(ctx, payload.Data.AgendaId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaScheduleResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if agendaData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("agenda not found")), + ).Throw(ctx) + + result = &AgendaScheduleResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + if agendaData.Status != "approved" { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaScheduleNotApproved), + exception.WithError(errors.New("only approved agendas can be scheduled")), + ).Throw(ctx) + + result = &AgendaScheduleResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + holder := new(data.Agenda) + if err := holder.UpdateFieldsByAgendaId(ctx, payload.Data.AgendaId, map[string]any{ + "start_time": payload.Data.StartTime, + "end_time": payload.Data.EndTime, + }); err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaScheduleResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &AgendaScheduleResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + } + return +} diff --git a/service/service_agenda/schedule_get.go b/service/service_agenda/schedule_get.go new file mode 100644 index 0000000..6708a83 --- /dev/null +++ b/service/service_agenda/schedule_get.go @@ -0,0 +1,107 @@ +package service_agenda + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + + "github.com/google/uuid" +) + +type AgendaScheduleGetData struct { + EventId uuid.UUID `json:"event_id" form:"event_id"` +} + +type AgendaScheduleGetPayload struct { + Context context.Context + Data *AgendaScheduleGetData +} + +type AgendaScheduleGetResult struct { + Common shared.CommonResult + Data *[]data.AgendaDoc +} + +func (self *AgendaServiceImpl) ScheduleGet(payload *AgendaScheduleGetPayload) (result *AgendaScheduleGetResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_agenda", + "schedule_get", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceAgendaScheduleGet) + + eventData, err := new(data.Event).GetEventById(ctx, payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaScheduleGetResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if eventData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("event not found")), + ).Throw(ctx) + + result = &AgendaScheduleGetResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + if !eventData.IsAgendaPublished { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaScheduleGetNotPublished), + exception.WithError(errors.New("agenda has not been published for this event")), + ).Throw(ctx) + + result = &AgendaScheduleGetResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + + agendas, err := new(data.Agenda).GetScheduledByEventId(ctx, payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaScheduleGetResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &AgendaScheduleGetResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + Data: agendas, + } + return +} diff --git a/service/service_agenda/service.go b/service/service_agenda/service.go index 4ccc3ce..505e7c1 100644 --- a/service/service_agenda/service.go +++ b/service/service_agenda/service.go @@ -2,6 +2,12 @@ package service_agenda type AgendaService interface { Submit(*SubmitPayload) *SubmitResult + Update(*AgendaUpdatePayload) *AgendaUpdateResult + Review(*AgendaReviewPayload) *AgendaReviewResult + Schedule(*AgendaSchedulePayload) *AgendaScheduleResult + List(*AgendaListPayload) *AgendaListResult + MyList(*AgendaMyListPayload) *AgendaMyListResult + ScheduleGet(*AgendaScheduleGetPayload) *AgendaScheduleGetResult } type AgendaServiceImpl struct{} diff --git a/service/service_agenda/submit.go b/service/service_agenda/submit.go index 9fd3c28..4391053 100644 --- a/service/service_agenda/submit.go +++ b/service/service_agenda/submit.go @@ -2,13 +2,14 @@ package service_agenda import ( "context" + "errors" "nixcn-cms/data" "nixcn-cms/internal/exception" "nixcn-cms/service/shared" "nixcn-cms/tracer" + "time" "github.com/google/uuid" - "gorm.io/gorm" ) type SubmitData struct { @@ -42,30 +43,10 @@ func (self *AgendaServiceImpl) Submit(payload *SubmitPayload) (result *SubmitRes ctx = exception.ContextWithService(ctx, exception.ServiceAgendaSubmit) - var err error - attendanceData, err := new(data.Attendance). GetAttendance(ctx, payload.UserId, payload.Data.EventId) if err != nil { - if err == gorm.ErrRecordNotFound { - exc := exception.New( - exception.WithStatus(exception.StatusUser), - exception.WithType(exception.TypeCommon), - exception.WithOriginal(exception.CommonErrorPermissionDenied), - exception.WithError(err), - ).Throw(ctx) - - result = &SubmitResult{ - Common: shared.CommonResult{ - HttpCode: 403, - Exception: exc, - }, - Data: nil, - } - - return - } exc := exception.New( exception.WithStatus(exception.StatusServer), exception.WithType(exception.TypeCommon), @@ -74,13 +55,108 @@ func (self *AgendaServiceImpl) Submit(payload *SubmitPayload) (result *SubmitRes ).Throw(ctx) result = &SubmitResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } + return + } + if attendanceData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorPermissionDenied), + exception.WithError(errors.New("user is not an attendee of this event")), + ).Throw(ctx) + + result = &SubmitResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + + eventData, err := new(data.Event).GetEventById(ctx, payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &SubmitResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if eventData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("event not found")), + ).Throw(ctx) + + result = &SubmitResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + if eventData.StartTime.Before(time.Now()) { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaSubmitEventStarted), + exception.WithError(errors.New("cannot submit agenda after event has started")), + ).Throw(ctx) + + result = &SubmitResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + if eventData.IsAgendaPublished { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaSubmitEventPublished), + exception.WithError(errors.New("cannot submit agenda after agenda has been published")), + ).Throw(ctx) + + result = &SubmitResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + pendingCount, err := new(data.Agenda).CountPendingByAttendanceId(ctx, attendanceData.AttendanceId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &SubmitResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if pendingCount >= 5 { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaSubmitPendingLimitReached), + exception.WithError(errors.New("maximum of 5 pending agendas allowed")), + ).Throw(ctx) + + result = &SubmitResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } return } @@ -88,7 +164,7 @@ func (self *AgendaServiceImpl) Submit(payload *SubmitPayload) (result *SubmitRes SetAttendanceId(attendanceData.AttendanceId). SetName(payload.Data.Name). SetDescription(payload.Data.Description). - SetIsApproved(false) + SetStatus("pending") err = agendaModel.Create(ctx) if err != nil { @@ -100,13 +176,8 @@ func (self *AgendaServiceImpl) Submit(payload *SubmitPayload) (result *SubmitRes ).Throw(ctx) result = &SubmitResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } - return } @@ -116,16 +187,14 @@ func (self *AgendaServiceImpl) Submit(payload *SubmitPayload) (result *SubmitRes exception.WithOriginal(exception.CommonSuccess), ).Throw(ctx) - resultData := &SubmitResponse{ - AgendaId: agendaModel.AgendaId, - } - result = &SubmitResult{ Common: shared.CommonResult{ HttpCode: 200, Exception: exc, }, - Data: resultData, + Data: &SubmitResponse{ + AgendaId: agendaModel.AgendaId, + }, } return diff --git a/service/service_agenda/update.go b/service/service_agenda/update.go new file mode 100644 index 0000000..11e58e9 --- /dev/null +++ b/service/service_agenda/update.go @@ -0,0 +1,193 @@ +package service_agenda + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + "time" + + "github.com/google/uuid" +) + +type AgendaUpdateData struct { + AgendaId uuid.UUID `json:"agenda_id"` + Name *string `json:"name"` + Description *string `json:"description"` + PermissionLevel uint `json:"permission_level" swaggerignore:"true"` +} + +type AgendaUpdatePayload struct { + Context context.Context + UserId uuid.UUID + Data *AgendaUpdateData +} + +type AgendaUpdateResult struct { + Common shared.CommonResult +} + +func (self *AgendaServiceImpl) Update(payload *AgendaUpdatePayload) (result *AgendaUpdateResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_agenda", + "update", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceAgendaUpdate) + + agendaData, err := new(data.Agenda).GetByAgendaId(ctx, payload.Data.AgendaId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if agendaData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("agenda not found")), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + isManager := payload.Data.PermissionLevel >= 30 + + if !isManager { + myAttendance, err := new(data.Attendance).GetAttendanceByAttendanceId(ctx, agendaData.AttendanceId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if myAttendance == nil || myAttendance.UserId != payload.UserId { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaUpdateNotSubmitter), + exception.WithError(errors.New("you are not the submitter of this agenda")), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + + if agendaData.Status != "pending" { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaUpdateNotPending), + exception.WithError(errors.New("submitters may only edit pending agendas")), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + eventData, err := new(data.Event).GetEventById(ctx, myAttendance.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if eventData != nil && eventData.StartTime.Before(time.Now()) { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.AgendaUpdateDeadlinePassed), + exception.WithError(errors.New("submission deadline has passed")), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + } + + updates := map[string]any{} + if payload.Data.Name != nil { + updates["name"] = *payload.Data.Name + } + if payload.Data.Description != nil { + updates["description"] = *payload.Data.Description + } + + if len(updates) == 0 { + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + } + return + } + + holder := new(data.Agenda) + if err := holder.UpdateFieldsByAgendaId(ctx, payload.Data.AgendaId, updates); err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &AgendaUpdateResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + } + return +} diff --git a/service/service_event/attendance_list.go b/service/service_event/attendance_list.go index 673a09a..c45b244 100644 --- a/service/service_event/attendance_list.go +++ b/service/service_event/attendance_list.go @@ -3,6 +3,7 @@ package service_event import ( "context" "encoding/json" + "errors" "nixcn-cms/data" "nixcn-cms/internal/cryptography" "nixcn-cms/internal/exception" @@ -10,17 +11,25 @@ import ( "nixcn-cms/service/service_user" "nixcn-cms/service/shared" "nixcn-cms/tracer" + "strconv" "github.com/google/uuid" "github.com/spf13/viper" ) type AttendanceListData struct { - EventId uuid.UUID `json:"event_id" form:"event_id"` + EventId uuid.UUID `json:"event_id" form:"event_id"` + Name string `json:"name" form:"name"` + KycStatus string `json:"kyc_status" form:"kyc_status"` // "with_kyc" | "without_kyc" | "" + Limit *string `json:"limit" form:"limit"` + Offset *string `json:"offset" form:"offset"` + SortBy *string `json:"sort_by" form:"sort_by"` + SortOrder *string `json:"sort_order" form:"sort_order"` } type AttendanceListPayload struct { Context context.Context + UserId uuid.UUID Data *AttendanceListData } @@ -31,9 +40,14 @@ type AttendanceListResponse struct { KycInfo any `json:"kyc_info"` } +type AttendanceListPagedResponse struct { + Total int64 `json:"total"` + Items []AttendanceListResponse `json:"items"` +} + type AttendanceListResult struct { Common shared.CommonResult - Data []AttendanceListResponse + Data *AttendanceListPagedResponse } func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (result *AttendanceListResult) { @@ -44,10 +58,85 @@ func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (re ) defer span.End() - attList, err := new(data.Attendance).GetAttendanceListByEventId(ctx, payload.Data.EventId) - ctx = exception.ContextWithService(ctx, exception.ServiceEventAttendanceList) + eventData, err := new(data.Event).GetEventById(ctx, payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &AttendanceListResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if eventData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("event not found")), + ).Throw(ctx) + + result = &AttendanceListResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + if eventData.Owner != payload.UserId { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.EventAttendanceListError), + exception.WithError(errors.New("only the event owner may view the attendance list")), + ).Throw(ctx) + + result = &AttendanceListResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + + limit := 20 + if payload.Data.Limit != nil && *payload.Data.Limit != "" { + if v, err := strconv.Atoi(*payload.Data.Limit); err == nil { + limit = v + } + } + + offset := 0 + if payload.Data.Offset != nil && *payload.Data.Offset != "" { + if v, err := strconv.Atoi(*payload.Data.Offset); err == nil { + offset = v + } + } + + sortBy := "" + if payload.Data.SortBy != nil { + sortBy = *payload.Data.SortBy + } + sortOrder := "" + if payload.Data.SortOrder != nil { + sortOrder = *payload.Data.SortOrder + } + + filter := data.AttendanceListFilter{ + EventId: payload.Data.EventId, + Name: payload.Data.Name, + KycStatus: payload.Data.KycStatus, + SortBy: sortBy, + SortOrder: sortOrder, + Limit: limit, + Offset: offset, + } + + attList, total, err := new(data.Attendance).GetAttendanceListFiltered(ctx, filter) if err != nil { exc := exception.New( exception.WithStatus(exception.StatusUser), @@ -57,10 +146,7 @@ func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (re ).Throw(ctx) result = &AttendanceListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } return } @@ -84,10 +170,7 @@ func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (re ).Throw(ctx) result = &AttendanceListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } return } @@ -112,7 +195,6 @@ func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (re if err == nil && kycData != nil { kycType = kycData.Type - // AES Decrypt decodedKycInfo, err := cryptography.AESCBCDecrypt(string(kycData.KycInfo), []byte(viper.GetString("secrets.kyc_info_key"))) if err != nil { exc := exception.New( @@ -128,7 +210,6 @@ func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (re return } - // JSON Unmarshal switch kycType { case "cnrid": var kycDetail kyc.CNRidInfo @@ -141,10 +222,7 @@ func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (re ).Throw(ctx) result = &AttendanceListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } return } @@ -161,10 +239,7 @@ func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (re ).Throw(ctx) result = &AttendanceListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } return } @@ -183,17 +258,18 @@ func (self *EventServiceImpl) AttendanceList(payload *AttendanceListPayload) (re }) } - result = &AttendanceListResult{ - Common: shared.CommonResult{ - HttpCode: 200, - Exception: exception.New( - exception.WithStatus(exception.StatusSuccess), - exception.WithType(exception.TypeCommon), - exception.WithOriginal(exception.CommonSuccess), - ).Throw(ctx), - }, - Data: responseList, - } + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + result = &AttendanceListResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + Data: &AttendanceListPagedResponse{ + Total: total, + Items: responseList, + }, + } return } diff --git a/service/service_event/create.go b/service/service_event/create.go new file mode 100644 index 0000000..6ce48e9 --- /dev/null +++ b/service/service_event/create.go @@ -0,0 +1,160 @@ +package service_event + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + "time" + + "github.com/google/uuid" +) + +type EventCreateData struct { + Type string `json:"type"` + EnableKYC bool `json:"enable_kyc"` + Name string `json:"name"` + Subtitle string `json:"subtitle"` + Description string `json:"description"` + AttendanceGuide string `json:"attendance_guide"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Thumbnail string `json:"thumbnail"` + Quota int64 `json:"quota"` + Limit int64 `json:"limit"` + UserId string `json:"user_id" swaggerignore:"true"` + PermissionLevel uint `json:"permission_level" swaggerignore:"true"` +} + +type EventCreatePayload struct { + Context context.Context + Data *EventCreateData +} + +type EventCreateResponse struct { + EventId string `json:"event_id" validate:"required"` +} + +type EventCreateResult struct { + Common shared.CommonResult + Data *EventCreateResponse +} + +func (self *EventServiceImpl) Create(payload *EventCreatePayload) (result *EventCreateResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_event", + "create", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceEventCreate) + + if payload.Data.Type != "party" && payload.Data.Type != "official" { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("type must be 'party' or 'official'")), + ).Throw(ctx) + + result = &EventCreateResult{ + Common: shared.CommonResult{ + HttpCode: 400, + Exception: exc, + }, + } + + return + } + + if payload.Data.PermissionLevel == 30 && payload.Data.Type != "party" { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.EventCreateTypeNotAllowed), + exception.WithError(errors.New("Lv30 users may only create events with type 'party'")), + ).Throw(ctx) + + result = &EventCreateResult{ + Common: shared.CommonResult{ + HttpCode: 403, + Exception: exc, + }, + } + + return + } + + ownerId, err := uuid.Parse(payload.Data.UserId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx) + + result = &EventCreateResult{ + Common: shared.CommonResult{ + HttpCode: 500, + Exception: exc, + }, + } + + return + } + + eventData := &data.Event{ + Type: payload.Data.Type, + EnableKYC: payload.Data.EnableKYC, + Name: payload.Data.Name, + Subtitle: payload.Data.Subtitle, + Description: payload.Data.Description, + AttendanceGuide: payload.Data.AttendanceGuide, + StartTime: payload.Data.StartTime, + EndTime: payload.Data.EndTime, + Thumbnail: payload.Data.Thumbnail, + Quota: payload.Data.Quota, + Limit: payload.Data.Limit, + Owner: ownerId, + } + + if err := eventData.Create(ctx); err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventCreateResult{ + Common: shared.CommonResult{ + HttpCode: 500, + Exception: exc, + }, + } + + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &EventCreateResult{ + Common: shared.CommonResult{ + HttpCode: 200, + Exception: exc, + }, + Data: &EventCreateResponse{ + EventId: eventData.EventId.String(), + }, + } + + return +} diff --git a/service/service_event/delete.go b/service/service_event/delete.go new file mode 100644 index 0000000..42f07c2 --- /dev/null +++ b/service/service_event/delete.go @@ -0,0 +1,91 @@ +package service_event + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type EventDeleteData struct { + EventId string `json:"event_id"` +} + +type EventDeletePayload struct { + Context context.Context + Data *EventDeleteData +} + +type EventDeleteResult struct { + Common shared.CommonResult +} + +func (self *EventServiceImpl) Delete(payload *EventDeletePayload) (result *EventDeleteResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_event", + "delete", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceEventDelete) + + eventId, err := uuid.Parse(payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx) + + result = &EventDeleteResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + if err := new(data.Event).DeleteEventById(ctx, eventId); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.EventDeleteNotFound), + exception.WithError(err), + ).Throw(ctx) + + result = &EventDeleteResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventDeleteResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &EventDeleteResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + } + return +} diff --git a/service/service_event/list.go b/service/service_event/list.go index 182ce4d..8550cad 100644 --- a/service/service_event/list.go +++ b/service/service_event/list.go @@ -13,8 +13,12 @@ import ( ) type EventListData struct { - Limit *string `json:"limit"` - Offset *string `json:"offset"` + Limit *string `json:"limit"` + Offset *string `json:"offset"` + Type *string `json:"type"` + SortBy *string `json:"sort_by"` + SortOrder *string `json:"sort_order"` + PermissionLevel uint `json:"permission_level" swaggerignore:"true"` } type EventListPayload struct { @@ -31,6 +35,7 @@ type EventListResponse struct { type EventListResult struct { Common shared.CommonResult + Total int64 `json:"total"` Data *[]EventListResponse `json:"event_list"` } @@ -44,7 +49,6 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList ctx = exception.ContextWithService(ctx, exception.ServiceEventList) - var err error var limit string if payload.Data.Limit == nil || *payload.Data.Limit == "" { limit = "20" @@ -62,14 +66,8 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList ).Throw(ctx) return &EventListResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - } else { - offset = *payload.Data.Offset } limitNum, err := strconv.Atoi(limit) @@ -82,13 +80,8 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList ).Throw(ctx) result = &EventListResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } @@ -102,18 +95,34 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList ).Throw(ctx) result = &EventListResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } - eventList, err := new(data.Event). - FastListEvents(ctx, int64(limitNum), int64(offsetNum)) + opts := data.EventListOptions{ + Limit: int64(limitNum), + Offset: int64(offsetNum), + } + + if payload.Data.Type != nil && *payload.Data.Type != "" { + opts.TypeFilter = *payload.Data.Type + } + + if payload.Data.SortBy != nil { + opts.SortBy = *payload.Data.SortBy + } + + if payload.Data.SortOrder != nil { + opts.SortOrder = *payload.Data.SortOrder + } + + // Lv30 users only see their own events + if payload.Data.PermissionLevel == 30 { + opts.OwnerId = &payload.UserId + } + + eventList, total, err := new(data.Event).ListEventsWithOptions(ctx, opts) if err != nil { exc := exception.New( exception.WithStatus(exception.StatusServer), @@ -123,11 +132,7 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList ).Throw(ctx) return &EventListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } } @@ -136,14 +141,12 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList if eventList != nil && len(*eventList) > 0 { var eventIds []uuid.UUID for _, e := range *eventList { - parsedId, parseErr := uuid.Parse(e.EventId) - if parseErr == nil { + if parsedId, parseErr := uuid.Parse(e.EventId); parseErr == nil { eventIds = append(eventIds, parsedId) } } joinedMap, err := new(data.Attendance).GetJoinedEventIDs(ctx, payload.UserId, eventIds) - if err != nil { exc := exception.New( exception.WithStatus(exception.StatusServer), @@ -153,18 +156,12 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList ).Throw(ctx) result = &EventListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } - return } checkedInMap, err := new(data.Attendance).GetCheckedInEventIDs(ctx, payload.UserId, eventIds) - if err != nil { exc := exception.New( exception.WithStatus(exception.StatusServer), @@ -174,25 +171,18 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList ).Throw(ctx) result = &EventListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } - return } for _, i := range *eventList { currentIdUuid, _ := uuid.Parse(i.EventId) - - response := EventListResponse{ + responseList = append(responseList, EventListResponse{ EventIndexDoc: i, IsJoined: joinedMap[currentIdUuid], IsCheckedIn: checkedInMap[currentIdUuid], - } - responseList = append(responseList, response) + }) } } @@ -203,12 +193,9 @@ func (self *EventServiceImpl) List(payload *EventListPayload) (result *EventList ).Throw(ctx) result = &EventListResult{ - Common: shared.CommonResult{ - HttpCode: 200, - Exception: exc, - }, - Data: &responseList, + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + Total: total, + Data: &responseList, } - return } diff --git a/service/service_event/service.go b/service/service_event/service.go index 411a4ba..b2234cd 100644 --- a/service/service_event/service.go +++ b/service/service_event/service.go @@ -9,6 +9,10 @@ type EventService interface { Join(*EventJoinPayload) *EventJoinResult AttendanceList(*AttendanceListPayload) *AttendanceListResult GetAttendanceGuide(*AttendanceGuidePayload) *AttendanceGuideResult + Create(*EventCreatePayload) *EventCreateResult + Update(*EventUpdatePayload) *EventUpdateResult + Delete(*EventDeletePayload) *EventDeleteResult + Stats(*EventStatsPayload) *EventStatsResult } type EventServiceImpl struct{} diff --git a/service/service_event/stats.go b/service/service_event/stats.go new file mode 100644 index 0000000..447c67a --- /dev/null +++ b/service/service_event/stats.go @@ -0,0 +1,187 @@ +package service_event + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + + "github.com/google/uuid" +) + +type EventStatsData struct { + EventId string `json:"event_id"` +} + +type EventStatsPayload struct { + Context context.Context + UserId uuid.UUID + Data *EventStatsData +} + +type EventStatsResponse struct { + JoinCount int64 `json:"join_count"` + CheckinCount int64 `json:"checkin_count"` + KycPassRate float64 `json:"kyc_pass_rate"` + AgendaSubmissionCount int64 `json:"agenda_submission_count"` +} + +type EventStatsResult struct { + Common shared.CommonResult + Data *EventStatsResponse +} + +func (self *EventServiceImpl) Stats(payload *EventStatsPayload) (result *EventStatsResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_event", + "stats", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceEventStats) + + eventId, err := uuid.Parse(payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + eventData, err := new(data.Event).GetEventById(ctx, eventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if eventData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("event not found")), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + if eventData.Owner != payload.UserId { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.EventStatsNotOwner), + exception.WithError(errors.New("only the event owner may view event stats")), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + + attRepo := new(data.Attendance) + + joinCount, err := attRepo.CountUsersByEventID(ctx, eventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + checkinCount, err := attRepo.CountCheckedInUsersByEventID(ctx, eventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + withKycCount, err := attRepo.CountWithKycByEventID(ctx, eventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + agendaCount, err := new(data.Agenda).CountByEventId(ctx, eventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + var kycPassRate float64 + if joinCount > 0 { + kycPassRate = float64(withKycCount) / float64(joinCount) * 100 + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &EventStatsResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + Data: &EventStatsResponse{ + JoinCount: joinCount, + CheckinCount: checkinCount, + KycPassRate: kycPassRate, + AgendaSubmissionCount: agendaCount, + }, + } + return +} diff --git a/service/service_event/update.go b/service/service_event/update.go new file mode 100644 index 0000000..8d2855d --- /dev/null +++ b/service/service_event/update.go @@ -0,0 +1,250 @@ +package service_event + +import ( + "context" + "errors" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" + "time" + + "github.com/google/uuid" +) + +type EventUpdateData struct { + EventId string `json:"event_id"` + Name *string `json:"name"` + Subtitle *string `json:"subtitle"` + Description *string `json:"description"` + AttendanceGuide *string `json:"attendance_guide"` + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + Thumbnail *string `json:"thumbnail"` + IsAgendaPublished *bool `json:"is_agenda_published"` + // immutable — presence triggers rejection + Type *string `json:"type" swaggerignore:"true"` + EnableKYC *bool `json:"enable_kyc" swaggerignore:"true"` + UserId string `json:"user_id" swaggerignore:"true"` +} + +type EventUpdatePayload struct { + Context context.Context + Data *EventUpdateData +} + +type EventUpdateResult struct { + Common shared.CommonResult +} + +func (self *EventServiceImpl) Update(payload *EventUpdatePayload) (result *EventUpdateResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_event", + "update", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceEventUpdate) + + if payload.Data.Type != nil || payload.Data.EnableKYC != nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.EventUpdateImmutableField), + exception.WithError(errors.New("type and enable_kyc are immutable after creation")), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + eventId, err := uuid.Parse(payload.Data.EventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + userId, err := uuid.Parse(payload.Data.UserId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + eventData, err := new(data.Event).GetEventById(ctx, eventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if eventData == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("event not found")), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + if eventData.Owner != userId { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.EventUpdateNotOwner), + exception.WithError(errors.New("only the event owner may update this event")), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + + updates := map[string]any{} + + if payload.Data.Name != nil { + updates["name"] = *payload.Data.Name + } + if payload.Data.Subtitle != nil { + updates["subtitle"] = *payload.Data.Subtitle + } + if payload.Data.Description != nil { + updates["description"] = *payload.Data.Description + } + if payload.Data.StartTime != nil { + updates["start_time"] = *payload.Data.StartTime + } + if payload.Data.EndTime != nil { + updates["end_time"] = *payload.Data.EndTime + } + if payload.Data.Thumbnail != nil { + updates["thumbnail"] = *payload.Data.Thumbnail + } + if payload.Data.AttendanceGuide != nil { + updates["attendance_guide"] = *payload.Data.AttendanceGuide + } + + if payload.Data.IsAgendaPublished != nil { + want := *payload.Data.IsAgendaPublished + + if eventData.IsAgendaPublished && !want { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.EventUpdateAgendaAlreadyPublished), + exception.WithError(errors.New("is_agenda_published is write-once and cannot be reverted")), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + if want && !eventData.IsAgendaPublished { + agendaCount, err := new(data.Agenda).CountByEventId(ctx, eventId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if agendaCount == 0 { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.EventUpdateAgendaPreflightFailed), + exception.WithError(errors.New("cannot publish agenda: no agenda submissions exist for this event")), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + + updates["is_agenda_published"] = true + } + } + + if len(updates) == 0 { + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + } + return + } + + holder := new(data.Event) + if err := holder.UpdateEventFieldsById(ctx, eventId, updates); err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &EventUpdateResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + } + return +} diff --git a/service/service_stats/global.go b/service/service_stats/global.go new file mode 100644 index 0000000..edb7787 --- /dev/null +++ b/service/service_stats/global.go @@ -0,0 +1,98 @@ +package service_stats + +import ( + "context" + "nixcn-cms/data" + "nixcn-cms/internal/exception" + "nixcn-cms/service/shared" + "nixcn-cms/tracer" +) + +type GlobalStatsPayload struct { + Context context.Context +} + +type GlobalStatsResponse struct { + TotalUsers int64 `json:"total_users"` + UsersPerLevel *[]data.PermissionLevelCount `json:"users_per_level"` + EventJoinCheckin *[]data.EventStatDoc `json:"event_join_checkin"` +} + +type GlobalStatsResult struct { + Common shared.CommonResult + Data *GlobalStatsResponse +} + +func (self *StatsServiceImpl) Global(payload *GlobalStatsPayload) (result *GlobalStatsResult) { + ctx, span := tracer.StartSpan( + payload.Context, + "service_stats", + "global", + ) + defer span.End() + + ctx = exception.ContextWithService(ctx, exception.ServiceStatsGlobal) + + statsRepo := new(data.GlobalStats) + + totalUsers, err := statsRepo.TotalUsers(ctx) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &GlobalStatsResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + usersPerLevel, err := statsRepo.UsersPerPermissionLevel(ctx) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &GlobalStatsResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + eventStats, err := statsRepo.EventJoinCheckinCounts(ctx) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &GlobalStatsResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + exc := exception.New( + exception.WithStatus(exception.StatusSuccess), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonSuccess), + ).Throw(ctx) + + result = &GlobalStatsResult{ + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + Data: &GlobalStatsResponse{ + TotalUsers: totalUsers, + UsersPerLevel: usersPerLevel, + EventJoinCheckin: eventStats, + }, + } + return +} diff --git a/service/service_stats/service.go b/service/service_stats/service.go new file mode 100644 index 0000000..2163373 --- /dev/null +++ b/service/service_stats/service.go @@ -0,0 +1,11 @@ +package service_stats + +type StatsService interface { + Global(*GlobalStatsPayload) *GlobalStatsResult +} + +type StatsServiceImpl struct{} + +func NewStatsService() StatsService { + return &StatsServiceImpl{} +} diff --git a/service/service_user/get_info.go b/service/service_user/get_info.go index 24698c9..ef66a6e 100644 --- a/service/service_user/get_info.go +++ b/service/service_user/get_info.go @@ -23,10 +23,12 @@ type UserInfoData struct { } type UserInfoPayload struct { - Context context.Context - UserId uuid.UUID - IsOther bool - Data *UserInfoData + Context context.Context + UserId uuid.UUID // target user + OperatorId uuid.UUID // calling user (for permission matrix on update) + OperatorLevel uint + IsOther bool + Data *UserInfoData } type UserInfoResult struct { diff --git a/service/service_user/list.go b/service/service_user/list.go index d711f91..52c91be 100644 --- a/service/service_user/list.go +++ b/service/service_user/list.go @@ -11,17 +11,21 @@ import ( ) type UserListPayload struct { - Context context.Context - Limit *string - Offset *string + Context context.Context + Limit *string + Offset *string + SortBy *string + SortOrder *string + PermissionLevel *uint } type UserListResponse struct { - data.UserIndexDoc + data.UserAdminDoc } type UserListResult struct { Common shared.CommonResult + Total int64 Data *[]UserListResponse `json:"user_list"` } @@ -35,11 +39,23 @@ func (self *UserServiceImpl) List(payload *UserListPayload) (result *UserListRes ctx = exception.ContextWithService(ctx, exception.ServiceUserList) - var limit string - if payload.Limit == nil || *payload.Limit == "" { - limit = "20" - } else { - limit = *payload.Limit + limit := 20 + if payload.Limit != nil && *payload.Limit != "" { + v, err := strconv.Atoi(*payload.Limit) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(err), + ).Throw(ctx) + + result = &UserListResult{ + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, + } + return + } + limit = v } var offset string @@ -52,39 +68,12 @@ func (self *UserServiceImpl) List(payload *UserListPayload) (result *UserListRes ).Throw(ctx) result = &UserListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return - } else { - offset = *payload.Offset } // Parse string to int64 - limitNum, err := strconv.Atoi(limit) - if err != nil { - exc := exception.New( - exception.WithStatus(exception.StatusUser), - exception.WithType(exception.TypeCommon), - exception.WithOriginal(exception.CommonErrorInvalidInput), - exception.WithError(err), - ).Throw(ctx) - - result = &UserListResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, - } - - return - } - offsetNum, err := strconv.Atoi(offset) if err != nil { exc := exception.New( @@ -95,19 +84,25 @@ func (self *UserServiceImpl) List(payload *UserListPayload) (result *UserListRes ).Throw(ctx) result = &UserListResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } - // Get user list from search engine - userList, err := new(data.User). - FastListUsers(ctx, &limitNum, &offsetNum) + opts := data.UserListOptions{ + Limit: limit, + Offset: offsetNum, + PermissionLevel: payload.PermissionLevel, + } + + if payload.SortBy != nil { + opts.SortBy = *payload.SortBy + } + if payload.SortOrder != nil { + opts.SortOrder = *payload.SortOrder + } + + userList, total, err := new(data.User).ListUsersFiltered(ctx, opts) if err != nil { exc := exception.New( exception.WithStatus(exception.StatusServer), @@ -117,19 +112,14 @@ func (self *UserServiceImpl) List(payload *UserListPayload) (result *UserListRes ).Throw(ctx) result = &UserListResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } + return } - response := make([]UserListResponse, 0) + response := make([]UserListResponse, 0, len(*userList)) for _, doc := range *userList { - response = append(response, UserListResponse{ - UserIndexDoc: doc, - }) + response = append(response, UserListResponse{UserAdminDoc: doc}) } exc := exception.New( @@ -139,12 +129,9 @@ func (self *UserServiceImpl) List(payload *UserListPayload) (result *UserListRes ).Throw(ctx) result = &UserListResult{ - Common: shared.CommonResult{ - HttpCode: 200, - Exception: exc, - }, - Data: &response, + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, + Total: total, + Data: &response, } - return } diff --git a/service/service_user/update_info.go b/service/service_user/update_info.go index baaeffb..9dd8c96 100644 --- a/service/service_user/update_info.go +++ b/service/service_user/update_info.go @@ -12,6 +12,7 @@ import ( ) type UserInfoUpdateData struct { + TargetUserId string `json:"user_id"` Username string `json:"username"` Nickname string `json:"nickname"` Subtitle string `json:"subtitle"` @@ -31,7 +32,7 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ctx = exception.ContextWithService(ctx, exception.ServiceUserUpdateInfo) - var err error + isAdminEdit := payload.OperatorLevel >= 40 && payload.UserId != payload.OperatorId updates := make(map[string]any) @@ -47,13 +48,8 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } } @@ -71,13 +67,8 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } if utf8.RuneCountInString(val) > 24 { @@ -89,13 +80,8 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } updates["nickname"] = val @@ -112,13 +98,8 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } updates["subtitle"] = *payload.Data.Subtitle @@ -137,13 +118,8 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } } @@ -162,13 +138,8 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 400, Exception: exc}, } - return } } @@ -179,6 +150,76 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI updates["allow_public"] = *payload.Data.AllowPublic } + userData := new(data.User) + + targetInfo, err := userData.GetByUserId(ctx, &payload.UserId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &UserInfoResult{ + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, + } + return + } + + if targetInfo == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUserNotFound), + exception.WithError(errors.New("target user not found")), + ).Throw(ctx) + + result = &UserInfoResult{ + Common: shared.CommonResult{HttpCode: 404, Exception: exc}, + } + return + } + + // Permission matrix: admin editing another user + if isAdminEdit { + if targetInfo.PermissionLevel >= payload.OperatorLevel { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.UserUpdatePermissionMatrixViolated), + exception.WithError(errors.New("cannot edit a user with equal or higher permission level")), + ).Throw(ctx) + + result = &UserInfoResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + } + + if payload.Data.PermissionLevel != nil { + newLevel := *payload.Data.PermissionLevel + // Only admins may change permission_level; the new level must be strictly below the operator's + if payload.OperatorLevel >= 40 { + if newLevel >= payload.OperatorLevel { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeSpecific), + exception.WithOriginal(exception.UserUpdatePermissionLevelTooHigh), + exception.WithError(errors.New("cannot grant a permission level equal to or higher than your own")), + ).Throw(ctx) + + result = &UserInfoResult{ + Common: shared.CommonResult{HttpCode: 403, Exception: exc}, + } + return + } + updates["permission_level"] = newLevel + } + // Non-admins silently ignore permission_level changes + } + if len(updates) == 0 { exc := exception.New( exception.WithStatus(exception.StatusSuccess), @@ -187,20 +228,12 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 200, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, } - return } - userData := new(data.User) - - userInfo, err := userData.GetByUserId(ctx, &payload.UserId) - if err != nil { + if err := userData.UpdateByUserID(ctx, &payload.UserId, updates); err != nil { exc := exception.New( exception.WithStatus(exception.StatusServer), exception.WithType(exception.TypeCommon), @@ -209,37 +242,8 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 500, Exception: exc}, } - - return - } - - if payload.Data.PermissionLevel != nil && userInfo.PermissionLevel >= 50 { - updates["permission_level"] = *payload.Data.PermissionLevel - } - - err = userData.UpdateByUserID(ctx, &payload.UserId, updates) - if err != nil { - exc := exception.New( - exception.WithStatus(exception.StatusServer), - exception.WithType(exception.TypeCommon), - exception.WithOriginal(exception.CommonErrorDatabase), - exception.WithError(err), - ).Throw(ctx) - - result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 500, - Exception: exc, - }, - Data: nil, - } - return } @@ -250,12 +254,7 @@ func (self *UserServiceImpl) UpdateInfo(payload *UserInfoPayload) (result *UserI ).Throw(ctx) result = &UserInfoResult{ - Common: shared.CommonResult{ - HttpCode: 200, - Exception: exc, - }, - Data: nil, + Common: shared.CommonResult{HttpCode: 200, Exception: exc}, } - return }