From 732598266cae21825e7e9fd0827d5a563309f722 Mon Sep 17 00:00:00 2001
From: Quaternions <krakow20@gmail.com>
Date: Tue, 1 Apr 2025 13:32:37 -0700
Subject: [PATCH] submissions: mapfixes

---
 pkg/model/mapfix.go              |  37 ++
 pkg/model/nats.go                |   7 +
 pkg/service/mapfixes.go          | 598 +++++++++++++++++++++++++++++++
 pkg/service_internal/mapfixes.go |  61 ++++
 4 files changed, 703 insertions(+)
 create mode 100644 pkg/model/mapfix.go
 create mode 100644 pkg/service/mapfixes.go
 create mode 100644 pkg/service_internal/mapfixes.go

diff --git a/pkg/model/mapfix.go b/pkg/model/mapfix.go
new file mode 100644
index 0000000..09e254a
--- /dev/null
+++ b/pkg/model/mapfix.go
@@ -0,0 +1,37 @@
+package model
+
+import "time"
+
+type MapfixStatus int32
+
+const (
+	// Phase: Final MapfixStatus
+	MapfixStatusRejected  MapfixStatus = 8
+	MapfixStatusUploaded   MapfixStatus = 7 // uploaded to the group, final status for mapfixes
+
+	// Phase: Testing
+	MapfixStatusUploading  MapfixStatus = 6
+	MapfixStatusValidated  MapfixStatus = 5
+	MapfixStatusValidating MapfixStatus = 4
+	MapfixStatusAccepted   MapfixStatus = 3 // pending script review, can re-trigger validation
+
+	// Phase: Creation
+	MapfixStatusChangesRequested  MapfixStatus = 2
+	MapfixStatusSubmitted         MapfixStatus = 1
+	MapfixStatusUnderConstruction MapfixStatus = 0
+)
+
+type Mapfix struct {
+	ID            int64 `gorm:"primaryKey"`
+	CreatedAt     time.Time
+	UpdatedAt     time.Time
+	Submitter     int64 // UserID
+	AssetID       int64
+	AssetVersion  int64
+	ValidatedAssetID       int64
+	ValidatedAssetVersion  int64
+	Completed     bool   // Has this version of the map been completed at least once on maptest
+	TargetAssetID int64 // where to upload map fix.  if the TargetAssetID is 0, it's a new map.
+	StatusID      MapfixStatus
+	StatusMessage string
+}
diff --git a/pkg/model/nats.go b/pkg/model/nats.go
index 60114b0..6add04f 100644
--- a/pkg/model/nats.go
+++ b/pkg/model/nats.go
@@ -13,6 +13,13 @@ type ValidateSubmissionRequest struct {
 	ValidatedModelID *int64 // optional value
 }
 
+type ValidateMapfixRequest struct {
+	MapfixID     int64
+	ModelID          int64
+	ModelVersion     int64
+	ValidatedModelID *int64 // optional value
+}
+
 // Create a new map
 type UploadSubmissionRequest struct {
 	SubmissionID int64
diff --git a/pkg/service/mapfixes.go b/pkg/service/mapfixes.go
new file mode 100644
index 0000000..f318661
--- /dev/null
+++ b/pkg/service/mapfixes.go
@@ -0,0 +1,598 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+
+	"git.itzana.me/strafesnet/maps-service/pkg/api"
+	"git.itzana.me/strafesnet/maps-service/pkg/datastore"
+	"git.itzana.me/strafesnet/maps-service/pkg/model"
+)
+
+var(
+	CreationPhaseMapfixesLimit = 20
+	CreationPhaseMapfixStatuses = []model.MapfixStatus{
+		model.MapfixStatusChangesRequested,
+		model.MapfixStatusSubmitted,
+		model.MapfixStatusUnderConstruction,
+	}
+	// prevent two mapfixes with same asset id
+	ActiveMapfixStatuses = []model.MapfixStatus{
+		model.MapfixStatusUploading,
+		model.MapfixStatusValidated,
+		model.MapfixStatusValidating,
+		model.MapfixStatusAccepted,
+		model.MapfixStatusChangesRequested,
+		model.MapfixStatusSubmitted,
+		model.MapfixStatusUnderConstruction,
+	}
+	// limit mapfixes in the pipeline to one per target map
+	ActiveAcceptedMapfixStatuses = []model.MapfixStatus{
+		model.MapfixStatusUploading,
+		model.MapfixStatusValidated,
+		model.MapfixStatusValidating,
+		model.MapfixStatusAccepted,
+	}
+)
+
+var (
+	ErrCreationPhaseMapfixesLimit = errors.New("Active mapfixes limited to 20")
+	ErrActiveMapfixSameAssetID = errors.New("There is an active mapfix with the same AssetID")
+	ErrActiveMapfixSameTargetAssetID = errors.New("There is an active mapfix with the same TargetAssetID")
+	ErrAcceptOwnMapfix = fmt.Errorf("%w: You cannot accept your own mapfix as the submitter", ErrPermissionDenied)
+)
+
+// POST /mapfixes
+func (svc *Service) CreateMapfix(ctx context.Context, request *api.MapfixCreate) (*api.ID, error) {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return nil, ErrUserInfo
+	}
+
+	userId, err := userInfo.GetUserID()
+	if err != nil {
+		return nil, err
+	}
+
+	// Check if user's mapfixes in the creation phase exceeds the limit
+	{
+		filter := datastore.Optional()
+		filter.Add("submitter", int64(userId))
+		filter.Add("status_id", CreationPhaseMapfixStatuses)
+		creation_mapfixes, err := svc.DB.Mapfixes().List(ctx, filter, model.Page{
+			Number: 1,
+			Size:   int32(CreationPhaseMapfixesLimit),
+		},datastore.ListSortDisabled)
+		if err != nil {
+			return nil, err
+		}
+
+		if CreationPhaseMapfixesLimit <= len(creation_mapfixes) {
+			return nil, ErrCreationPhaseMapfixesLimit
+		}
+	}
+
+	// Check if an active mapfix with the same asset id exists
+	{
+		filter := datastore.Optional()
+		filter.Add("asset_id", request.AssetID)
+		filter.Add("asset_version", request.AssetVersion)
+		filter.Add("status_id", ActiveMapfixStatuses)
+		active_mapfixes, err := svc.DB.Mapfixes().List(ctx, filter, model.Page{
+			Number: 1,
+			Size:   1,
+		},datastore.ListSortDisabled)
+		if err != nil {
+			return nil, err
+		}
+		if len(active_mapfixes) != 0{
+			return nil, ErrActiveMapfixSameAssetID
+		}
+	}
+
+	mapfix, err := svc.DB.Mapfixes().Create(ctx, model.Mapfix{
+		ID:            0,
+		Submitter:     int64(userId),
+		AssetID:       request.AssetID,
+		AssetVersion:  request.AssetVersion,
+		Completed:     false,
+		TargetAssetID: request.TargetAssetID,
+		StatusID:      model.MapfixStatusUnderConstruction,
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &api.ID{
+		ID: mapfix.ID,
+	}, nil
+}
+
+// GetMapfix implements getMapfix operation.
+//
+// Retrieve map with ID.
+//
+// GET /mapfixes/{MapfixID}
+func (svc *Service) GetMapfix(ctx context.Context, params api.GetMapfixParams) (*api.Mapfix, error) {
+	mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
+	if err != nil {
+		return nil, err
+	}
+	return &api.Mapfix{
+		ID:            mapfix.ID,
+		CreatedAt:     mapfix.CreatedAt.Unix(),
+		UpdatedAt:     mapfix.UpdatedAt.Unix(),
+		Submitter:     int64(mapfix.Submitter),
+		AssetID:       int64(mapfix.AssetID),
+		AssetVersion:  int64(mapfix.AssetVersion),
+		Completed:     mapfix.Completed,
+		TargetAssetID: int64(mapfix.TargetAssetID),
+		StatusID:      int32(mapfix.StatusID),
+		StatusMessage: mapfix.StatusMessage,
+	}, nil
+}
+
+// ListMapfixes implements listMapfixes operation.
+//
+// Get list of mapfixes.
+//
+// GET /mapfixes
+func (svc *Service) ListMapfixes(ctx context.Context, params api.ListMapfixesParams) ([]api.Mapfix, error) {
+	filter := datastore.Optional()
+
+	sort := datastore.ListSort(params.Sort.Or(int32(datastore.ListSortDisabled)))
+
+	items, err := svc.DB.Mapfixes().List(ctx, filter, model.Page{
+		Number: params.Page,
+		Size:   params.Limit,
+	},sort)
+	if err != nil {
+		return nil, err
+	}
+
+	var resp []api.Mapfix
+	for _, item := range items {
+		resp = append(resp, api.Mapfix{
+			ID:            item.ID,
+			CreatedAt:     item.CreatedAt.Unix(),
+			UpdatedAt:     item.UpdatedAt.Unix(),
+			Submitter:     int64(item.Submitter),
+			AssetID:       int64(item.AssetID),
+			AssetVersion:  int64(item.AssetVersion),
+			Completed:     item.Completed,
+			TargetAssetID: int64(item.TargetAssetID),
+			StatusID:      int32(item.StatusID),
+		})
+	}
+
+	return resp, nil
+}
+
+// PatchMapfixCompleted implements patchMapfixCompleted operation.
+//
+// Retrieve map with ID.
+//
+// POST /mapfixes/{MapfixID}/completed
+func (svc *Service) SetMapfixCompleted(ctx context.Context, params api.SetMapfixCompletedParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	has_role, err := userInfo.HasRoleMaptest()
+	if err != nil {
+		return err
+	}
+	// check if caller has MaptestGame role (request must originate from a maptest roblox game)
+	if !has_role {
+		return ErrPermissionDeniedNeedRoleMaptest
+	}
+
+	pmap := datastore.Optional()
+	pmap.Add("completed", true)
+	return svc.DB.Mapfixes().Update(ctx, params.MapfixID, pmap)
+}
+
+// UpdateMapfixModel implements patchMapfixModel operation.
+//
+// Update model following role restrictions.
+//
+// POST /mapfixes/{MapfixID}/model
+func (svc *Service) UpdateMapfixModel(ctx context.Context, params api.UpdateMapfixModelParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	// read mapfix (this could be done with a transaction WHERE clause)
+	mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
+	if err != nil {
+		return err
+	}
+
+	has_role, err := userInfo.IsSubmitter(uint64(mapfix.Submitter))
+	if err != nil {
+		return err
+	}
+	// check if caller is the submitter
+	if !has_role {
+		return ErrPermissionDeniedNotSubmitter
+	}
+
+	// check if Status is ChangesRequested|Submitted|UnderConstruction
+	pmap := datastore.Optional()
+	pmap.AddNotNil("asset_id", params.ModelID)
+	pmap.AddNotNil("asset_version", params.VersionID)
+	//always reset completed when model changes
+	pmap.Add("completed", false)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusChangesRequested, model.MapfixStatusSubmitted, model.MapfixStatusUnderConstruction}, pmap)
+}
+
+// ActionMapfixReject invokes actionMapfixReject operation.
+//
+// Role Reviewer changes status from Submitted -> Rejected.
+//
+// POST /mapfixes/{MapfixID}/status/reject
+func (svc *Service) ActionMapfixReject(ctx context.Context, params api.ActionMapfixRejectParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	has_role, err := userInfo.HasRoleMapfixReview()
+	if err != nil {
+		return err
+	}
+	// check if caller has required role
+	if !has_role {
+		return ErrPermissionDeniedNeedRoleMapReview
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusRejected)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusSubmitted}, smap)
+}
+
+// ActionMapfixRequestChanges invokes actionMapfixRequestChanges operation.
+//
+// Role Reviewer changes status from Validated|Accepted|Submitted -> ChangesRequested.
+//
+// POST /mapfixes/{MapfixID}/status/request-changes
+func (svc *Service) ActionMapfixRequestChanges(ctx context.Context, params api.ActionMapfixRequestChangesParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	has_role, err := userInfo.HasRoleMapfixReview()
+	if err != nil {
+		return err
+	}
+	// check if caller has required role
+	if !has_role {
+		return ErrPermissionDeniedNeedRoleMapReview
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusChangesRequested)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated, model.MapfixStatusAccepted, model.MapfixStatusSubmitted}, smap)
+}
+
+// ActionMapfixRevoke invokes actionMapfixRevoke operation.
+//
+// Role Submitter changes status from Submitted|ChangesRequested -> UnderConstruction.
+//
+// POST /mapfixes/{MapfixID}/status/revoke
+func (svc *Service) ActionMapfixRevoke(ctx context.Context, params api.ActionMapfixRevokeParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	// read mapfix (this could be done with a transaction WHERE clause)
+	mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
+	if err != nil {
+		return err
+	}
+
+	has_role, err := userInfo.IsSubmitter(uint64(mapfix.Submitter))
+	if err != nil {
+		return err
+	}
+	// check if caller is the submitter
+	if !has_role {
+		return ErrPermissionDeniedNotSubmitter
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusUnderConstruction)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusSubmitted, model.MapfixStatusChangesRequested}, smap)
+}
+
+// ActionMapfixSubmit invokes actionMapfixSubmit operation.
+//
+// Role Submitter changes status from UnderConstruction|ChangesRequested -> Submitted.
+//
+// POST /mapfixes/{MapfixID}/status/submit
+func (svc *Service) ActionMapfixSubmit(ctx context.Context, params api.ActionMapfixSubmitParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	// read mapfix (this could be done with a transaction WHERE clause)
+	mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
+	if err != nil {
+		return err
+	}
+
+	has_role, err := userInfo.IsSubmitter(uint64(mapfix.Submitter))
+	if err != nil {
+		return err
+	}
+	// check if caller is the submitter
+	if !has_role {
+		return ErrPermissionDeniedNotSubmitter
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusSubmitted)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusUnderConstruction, model.MapfixStatusChangesRequested}, smap)
+}
+
+// ActionMapfixTriggerUpload invokes actionMapfixTriggerUpload operation.
+//
+// Role Admin changes status from Validated -> Uploading.
+//
+// POST /mapfixes/{MapfixID}/status/trigger-upload
+func (svc *Service) ActionMapfixTriggerUpload(ctx context.Context, params api.ActionMapfixTriggerUploadParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	has_role, err := userInfo.HasRoleMapfixUpload()
+	if err != nil {
+		return err
+	}
+	// check if caller has required role
+	if !has_role {
+		return ErrPermissionDeniedNeedRoleMapUpload
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusUploading)
+	mapfix, err := svc.DB.Mapfixes().IfStatusThenUpdateAndGet(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated}, smap)
+	if err != nil {
+		return err
+	}
+
+	// this is a map fix
+	upload_fix_request := model.UploadMapfixRequest{
+		MapfixID:      mapfix.ID,
+		ModelID:       mapfix.ValidatedAssetID,
+		ModelVersion:  mapfix.ValidatedAssetVersion,
+		TargetAssetID: mapfix.TargetAssetID,
+	}
+
+	j, err := json.Marshal(upload_fix_request)
+	if err != nil {
+		return err
+	}
+
+	svc.Nats.Publish("maptest.mapfixes.uploadfix", []byte(j))
+
+	return nil
+}
+
+// ActionMapfixValidate invokes actionMapfixValidate operation.
+//
+// Role MapfixRelease changes status from Uploading -> Validated.
+//
+// POST /mapfixes/{MapfixID}/status/reset-uploading
+func (svc *Service) ActionMapfixValidated(ctx context.Context, params api.ActionMapfixValidatedParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	has_role, err := userInfo.HasRoleMapfixUpload()
+	if err != nil {
+		return err
+	}
+	// check if caller has required role
+	if !has_role {
+		return ErrPermissionDeniedNeedRoleMapUpload
+	}
+
+	// check when mapfix was updated
+	mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
+	if err != nil {
+		return err
+	}
+	if time.Now().Before(mapfix.UpdatedAt.Add(time.Second*10)) {
+		// the last time the mapfix was updated must be longer than 10 seconds ago
+		return ErrDelayReset
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusValidated)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusUploading}, smap)
+}
+
+// ActionMapfixTriggerValidate invokes actionMapfixTriggerValidate operation.
+//
+// Role Reviewer triggers validation and changes status from Submitted -> Validating.
+//
+// POST /mapfixes/{MapfixID}/status/trigger-validate
+func (svc *Service) ActionMapfixTriggerValidate(ctx context.Context, params api.ActionMapfixTriggerValidateParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	has_role, err := userInfo.HasRoleMapfixReview()
+	if err != nil {
+		return err
+	}
+	// check if caller has required role
+	if !has_role {
+		return ErrPermissionDeniedNeedRoleMapReview
+	}
+
+	// read mapfix (this could be done with a transaction WHERE clause)
+	mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
+	if err != nil {
+		return err
+	}
+
+	has_role, err = userInfo.IsSubmitter(uint64(mapfix.Submitter))
+	if err != nil {
+		return err
+	}
+	// check if caller is NOT the submitter
+	if has_role {
+		return ErrAcceptOwnMapfix
+	}
+
+	// Check if an active mapfix with the same target asset id exists
+	if mapfix.TargetAssetID != 0 {
+		filter := datastore.Optional()
+		filter.Add("target_asset_id", mapfix.TargetAssetID)
+		filter.Add("status_id", ActiveAcceptedMapfixStatuses)
+		active_mapfixes, err := svc.DB.Mapfixes().List(ctx, filter, model.Page{
+			Number: 1,
+			Size:   1,
+		},datastore.ListSortDisabled)
+		if err != nil {
+			return err
+		}
+		if len(active_mapfixes) != 0{
+			return ErrActiveMapfixSameTargetAssetID
+		}
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusValidating)
+	mapfix, err = svc.DB.Mapfixes().IfStatusThenUpdateAndGet(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusSubmitted}, smap)
+	if err != nil {
+		return err
+	}
+
+	validate_request := model.ValidateMapfixRequest{
+		MapfixID:         mapfix.ID,
+		ModelID:          mapfix.AssetID,
+		ModelVersion:     mapfix.AssetVersion,
+		ValidatedModelID: nil,
+	}
+
+	// sentinel values because we're not using rust
+	if mapfix.ValidatedAssetID != 0 {
+		validate_request.ValidatedModelID = &mapfix.ValidatedAssetID
+	}
+
+	j, err := json.Marshal(validate_request)
+	if err != nil {
+		return err
+	}
+
+	svc.Nats.Publish("maptest.mapfixes.validate", []byte(j))
+
+	return nil
+}
+
+// ActionMapfixRetryValidate invokes actionMapfixRetryValidate operation.
+//
+// Role Reviewer re-runs validation and changes status from Accepted -> Validating.
+//
+// POST /mapfixes/{MapfixID}/status/retry-validate
+func (svc *Service) ActionMapfixRetryValidate(ctx context.Context, params api.ActionMapfixRetryValidateParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	has_role, err := userInfo.HasRoleMapfixReview()
+	if err != nil {
+		return err
+	}
+	// check if caller has required role
+	if !has_role {
+		return ErrPermissionDeniedNeedRoleMapReview
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusValidating)
+	mapfix, err := svc.DB.Mapfixes().IfStatusThenUpdateAndGet(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusAccepted}, smap)
+	if err != nil {
+		return err
+	}
+
+	validate_request := model.ValidateMapfixRequest{
+		MapfixID:     mapfix.ID,
+		ModelID:          mapfix.AssetID,
+		ModelVersion:     mapfix.AssetVersion,
+		ValidatedModelID: nil,
+	}
+
+	// sentinel values because we're not using rust
+	if mapfix.ValidatedAssetID != 0 {
+		validate_request.ValidatedModelID = &mapfix.ValidatedAssetID
+	}
+
+	j, err := json.Marshal(validate_request)
+	if err != nil {
+		return err
+	}
+
+	svc.Nats.Publish("maptest.mapfixes.validate", []byte(j))
+
+	return nil
+}
+
+// ActionMapfixAccepted implements actionMapfixAccepted operation.
+//
+// Role MapfixReview changes status from Validating -> Accepted.
+//
+// POST /mapfixes/{MapfixID}/status/reset-validating
+func (svc *Service) ActionMapfixAccepted(ctx context.Context, params api.ActionMapfixAcceptedParams) error {
+	userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
+	if !ok {
+		return ErrUserInfo
+	}
+
+	has_role, err := userInfo.HasRoleMapfixReview()
+	if err != nil {
+		return err
+	}
+	// check if caller has required role
+	if !has_role {
+		return ErrPermissionDeniedNeedRoleMapReview
+	}
+
+	// check when mapfix was updated
+	mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
+	if err != nil {
+		return err
+	}
+	if time.Now().Before(mapfix.UpdatedAt.Add(time.Second*10)) {
+		// the last time the mapfix was updated must be longer than 10 seconds ago
+		return ErrDelayReset
+	}
+
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusAccepted)
+	smap.Add("status_message", "Manually forced reset")
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidating}, smap)
+}
diff --git a/pkg/service_internal/mapfixes.go b/pkg/service_internal/mapfixes.go
new file mode 100644
index 0000000..cfdf31a
--- /dev/null
+++ b/pkg/service_internal/mapfixes.go
@@ -0,0 +1,61 @@
+package service_internal
+
+import (
+	"context"
+
+	internal "git.itzana.me/strafesnet/maps-service/pkg/internal"
+	"git.itzana.me/strafesnet/maps-service/pkg/datastore"
+	"git.itzana.me/strafesnet/maps-service/pkg/model"
+)
+
+// UpdateMapfixValidatedModel implements patchMapfixModel operation.
+//
+// Update model following role restrictions.
+//
+// POST /mapfixes/{MapfixID}/validated-model
+func (svc *Service) UpdateMapfixValidatedModel(ctx context.Context, params internal.UpdateMapfixValidatedModelParams) error {
+	// check if Status is ChangesRequested|Submitted|UnderConstruction
+	pmap := datastore.Optional()
+	pmap.AddNotNil("validated_asset_id", params.ValidatedModelID)
+	pmap.AddNotNil("validated_asset_version", params.ValidatedModelVersion)
+	// DO NOT reset completed when validated model is updated
+	// pmap.Add("completed", false)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidating}, pmap)
+}
+
+// ActionMapfixValidate invokes actionMapfixValidate operation.
+//
+// Role Validator changes status from Validating -> Validated.
+//
+// POST /mapfixes/{MapfixID}/status/validator-validated
+func (svc *Service) ActionMapfixValidated(ctx context.Context, params internal.ActionMapfixValidatedParams) error {
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusValidated)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidating}, smap)
+}
+
+// ActionMapfixAccepted implements actionMapfixAccepted operation.
+//
+// (Internal endpoint) Role Validator changes status from Validating -> Accepted.
+//
+// POST /mapfixes/{MapfixID}/status/validator-failed
+func (svc *Service) ActionMapfixAccepted(ctx context.Context, params internal.ActionMapfixAcceptedParams) error {
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusAccepted)
+	smap.Add("status_message", params.StatusMessage)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidating}, smap)
+}
+
+// ActionMapfixUploaded implements actionMapfixUploaded operation.
+//
+// (Internal endpoint) Role Validator changes status from Uploading -> Uploaded.
+//
+// POST /mapfixes/{MapfixID}/status/validator-uploaded
+func (svc *Service) ActionMapfixUploaded(ctx context.Context, params internal.ActionMapfixUploadedParams) error {
+	// transaction
+	smap := datastore.Optional()
+	smap.Add("status_id", model.MapfixStatusUploaded)
+	return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusUploading}, smap)
+}