Deploy nudges and action confirmation #311
24
openapi.yaml
24
openapi.yaml
@@ -447,6 +447,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
/mapfixes/{MapfixID}/description:
|
||||
patch:
|
||||
summary: Update description (submitter only)
|
||||
operationId: updateMapfixDescription
|
||||
tags:
|
||||
- Mapfixes
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/MapfixID'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 256
|
||||
responses:
|
||||
"204":
|
||||
description: Successful response
|
||||
default:
|
||||
description: General Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
/mapfixes/{MapfixID}/completed:
|
||||
post:
|
||||
summary: Called by maptest when a player completes the map
|
||||
|
||||
@@ -385,6 +385,12 @@ type Invoker interface {
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/completed
|
||||
SetSubmissionCompleted(ctx context.Context, params SetSubmissionCompletedParams) error
|
||||
// UpdateMapfixDescription invokes updateMapfixDescription operation.
|
||||
//
|
||||
// Update description (submitter only).
|
||||
//
|
||||
// PATCH /mapfixes/{MapfixID}/description
|
||||
UpdateMapfixDescription(ctx context.Context, request UpdateMapfixDescriptionReq, params UpdateMapfixDescriptionParams) error
|
||||
// UpdateMapfixModel invokes updateMapfixModel operation.
|
||||
//
|
||||
// Update model following role restrictions.
|
||||
@@ -7701,6 +7707,134 @@ func (c *Client) sendSetSubmissionCompleted(ctx context.Context, params SetSubmi
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateMapfixDescription invokes updateMapfixDescription operation.
|
||||
//
|
||||
// Update description (submitter only).
|
||||
//
|
||||
// PATCH /mapfixes/{MapfixID}/description
|
||||
func (c *Client) UpdateMapfixDescription(ctx context.Context, request UpdateMapfixDescriptionReq, params UpdateMapfixDescriptionParams) error {
|
||||
_, err := c.sendUpdateMapfixDescription(ctx, request, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) sendUpdateMapfixDescription(ctx context.Context, request UpdateMapfixDescriptionReq, params UpdateMapfixDescriptionParams) (res *UpdateMapfixDescriptionNoContent, err error) {
|
||||
otelAttrs := []attribute.KeyValue{
|
||||
otelogen.OperationID("updateMapfixDescription"),
|
||||
semconv.HTTPRequestMethodKey.String("PATCH"),
|
||||
semconv.URLTemplateKey.String("/mapfixes/{MapfixID}/description"),
|
||||
}
|
||||
otelAttrs = append(otelAttrs, c.cfg.Attributes...)
|
||||
|
||||
// Run stopwatch.
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
// Use floating point division here for higher precision (instead of Millisecond method).
|
||||
elapsedDuration := time.Since(startTime)
|
||||
c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...))
|
||||
}()
|
||||
|
||||
// Increment request counter.
|
||||
c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
|
||||
|
||||
// Start a span for this request.
|
||||
ctx, span := c.cfg.Tracer.Start(ctx, UpdateMapfixDescriptionOperation,
|
||||
trace.WithAttributes(otelAttrs...),
|
||||
clientSpanKind,
|
||||
)
|
||||
// Track stage for error reporting.
|
||||
var stage string
|
||||
defer func() {
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, stage)
|
||||
c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
|
||||
}
|
||||
span.End()
|
||||
}()
|
||||
|
||||
stage = "BuildURL"
|
||||
u := uri.Clone(c.requestURL(ctx))
|
||||
var pathParts [3]string
|
||||
pathParts[0] = "/mapfixes/"
|
||||
{
|
||||
// Encode "MapfixID" parameter.
|
||||
e := uri.NewPathEncoder(uri.PathEncoderConfig{
|
||||
Param: "MapfixID",
|
||||
Style: uri.PathStyleSimple,
|
||||
Explode: false,
|
||||
})
|
||||
if err := func() error {
|
||||
return e.EncodeValue(conv.Int64ToString(params.MapfixID))
|
||||
}(); err != nil {
|
||||
return res, errors.Wrap(err, "encode path")
|
||||
}
|
||||
encoded, err := e.Result()
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "encode path")
|
||||
}
|
||||
pathParts[1] = encoded
|
||||
}
|
||||
pathParts[2] = "/description"
|
||||
uri.AddPathParts(u, pathParts[:]...)
|
||||
|
||||
stage = "EncodeRequest"
|
||||
r, err := ht.NewRequest(ctx, "PATCH", u)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "create request")
|
||||
}
|
||||
if err := encodeUpdateMapfixDescriptionRequest(request, r); err != nil {
|
||||
return res, errors.Wrap(err, "encode request")
|
||||
}
|
||||
|
||||
{
|
||||
type bitset = [1]uint8
|
||||
var satisfied bitset
|
||||
{
|
||||
stage = "Security:CookieAuth"
|
||||
switch err := c.securityCookieAuth(ctx, UpdateMapfixDescriptionOperation, r); {
|
||||
case err == nil: // if NO error
|
||||
satisfied[0] |= 1 << 0
|
||||
case errors.Is(err, ogenerrors.ErrSkipClientSecurity):
|
||||
// Skip this security.
|
||||
default:
|
||||
return res, errors.Wrap(err, "security \"CookieAuth\"")
|
||||
}
|
||||
}
|
||||
|
||||
if ok := func() bool {
|
||||
nextRequirement:
|
||||
for _, requirement := range []bitset{
|
||||
{0b00000001},
|
||||
} {
|
||||
for i, mask := range requirement {
|
||||
if satisfied[i]&mask != mask {
|
||||
continue nextRequirement
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}(); !ok {
|
||||
return res, ogenerrors.ErrSecurityRequirementIsNotSatisfied
|
||||
}
|
||||
}
|
||||
|
||||
stage = "SendRequest"
|
||||
resp, err := c.cfg.Client.Do(r)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "do request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
stage = "DecodeResponse"
|
||||
result, err := decodeUpdateMapfixDescriptionResponse(resp)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "decode response")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateMapfixModel invokes updateMapfixModel operation.
|
||||
//
|
||||
// Update model following role restrictions.
|
||||
|
||||
@@ -11020,6 +11020,219 @@ func (s *Server) handleSetSubmissionCompletedRequest(args [1]string, argsEscaped
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpdateMapfixDescriptionRequest handles updateMapfixDescription operation.
|
||||
//
|
||||
// Update description (submitter only).
|
||||
//
|
||||
// PATCH /mapfixes/{MapfixID}/description
|
||||
func (s *Server) handleUpdateMapfixDescriptionRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) {
|
||||
statusWriter := &codeRecorder{ResponseWriter: w}
|
||||
w = statusWriter
|
||||
otelAttrs := []attribute.KeyValue{
|
||||
otelogen.OperationID("updateMapfixDescription"),
|
||||
semconv.HTTPRequestMethodKey.String("PATCH"),
|
||||
semconv.HTTPRouteKey.String("/mapfixes/{MapfixID}/description"),
|
||||
}
|
||||
|
||||
// Start a span for this request.
|
||||
ctx, span := s.cfg.Tracer.Start(r.Context(), UpdateMapfixDescriptionOperation,
|
||||
trace.WithAttributes(otelAttrs...),
|
||||
serverSpanKind,
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
// Add Labeler to context.
|
||||
labeler := &Labeler{attrs: otelAttrs}
|
||||
ctx = contextWithLabeler(ctx, labeler)
|
||||
|
||||
// Run stopwatch.
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
elapsedDuration := time.Since(startTime)
|
||||
|
||||
attrSet := labeler.AttributeSet()
|
||||
attrs := attrSet.ToSlice()
|
||||
code := statusWriter.status
|
||||
if code != 0 {
|
||||
codeAttr := semconv.HTTPResponseStatusCode(code)
|
||||
attrs = append(attrs, codeAttr)
|
||||
span.SetAttributes(codeAttr)
|
||||
}
|
||||
attrOpt := metric.WithAttributes(attrs...)
|
||||
|
||||
// Increment request counter.
|
||||
s.requests.Add(ctx, 1, attrOpt)
|
||||
|
||||
// Use floating point division here for higher precision (instead of Millisecond method).
|
||||
s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt)
|
||||
}()
|
||||
|
||||
var (
|
||||
recordError = func(stage string, err error) {
|
||||
span.RecordError(err)
|
||||
|
||||
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status
|
||||
// Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges,
|
||||
// unless there was another error (e.g., network error receiving the response body; or 3xx codes with
|
||||
// max redirects exceeded), in which case status MUST be set to Error.
|
||||
code := statusWriter.status
|
||||
if code < 100 || code >= 500 {
|
||||
span.SetStatus(codes.Error, stage)
|
||||
}
|
||||
|
||||
attrSet := labeler.AttributeSet()
|
||||
attrs := attrSet.ToSlice()
|
||||
if code != 0 {
|
||||
attrs = append(attrs, semconv.HTTPResponseStatusCode(code))
|
||||
}
|
||||
|
||||
s.errors.Add(ctx, 1, metric.WithAttributes(attrs...))
|
||||
}
|
||||
err error
|
||||
opErrContext = ogenerrors.OperationContext{
|
||||
Name: UpdateMapfixDescriptionOperation,
|
||||
ID: "updateMapfixDescription",
|
||||
}
|
||||
)
|
||||
{
|
||||
type bitset = [1]uint8
|
||||
var satisfied bitset
|
||||
{
|
||||
sctx, ok, err := s.securityCookieAuth(ctx, UpdateMapfixDescriptionOperation, r)
|
||||
if err != nil {
|
||||
err = &ogenerrors.SecurityError{
|
||||
OperationContext: opErrContext,
|
||||
Security: "CookieAuth",
|
||||
Err: err,
|
||||
}
|
||||
if encodeErr := encodeErrorResponse(s.h.NewError(ctx, err), w, span); encodeErr != nil {
|
||||
defer recordError("Security:CookieAuth", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
satisfied[0] |= 1 << 0
|
||||
ctx = sctx
|
||||
}
|
||||
}
|
||||
|
||||
if ok := func() bool {
|
||||
nextRequirement:
|
||||
for _, requirement := range []bitset{
|
||||
{0b00000001},
|
||||
} {
|
||||
for i, mask := range requirement {
|
||||
if satisfied[i]&mask != mask {
|
||||
continue nextRequirement
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}(); !ok {
|
||||
err = &ogenerrors.SecurityError{
|
||||
OperationContext: opErrContext,
|
||||
Err: ogenerrors.ErrSecurityRequirementIsNotSatisfied,
|
||||
}
|
||||
if encodeErr := encodeErrorResponse(s.h.NewError(ctx, err), w, span); encodeErr != nil {
|
||||
defer recordError("Security", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
params, err := decodeUpdateMapfixDescriptionParams(args, argsEscaped, r)
|
||||
if err != nil {
|
||||
err = &ogenerrors.DecodeParamsError{
|
||||
OperationContext: opErrContext,
|
||||
Err: err,
|
||||
}
|
||||
defer recordError("DecodeParams", err)
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var rawBody []byte
|
||||
request, rawBody, close, err := s.decodeUpdateMapfixDescriptionRequest(r)
|
||||
if err != nil {
|
||||
err = &ogenerrors.DecodeRequestError{
|
||||
OperationContext: opErrContext,
|
||||
Err: err,
|
||||
}
|
||||
defer recordError("DecodeRequest", err)
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := close(); err != nil {
|
||||
recordError("CloseRequest", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var response *UpdateMapfixDescriptionNoContent
|
||||
if m := s.cfg.Middleware; m != nil {
|
||||
mreq := middleware.Request{
|
||||
Context: ctx,
|
||||
OperationName: UpdateMapfixDescriptionOperation,
|
||||
OperationSummary: "Update description (submitter only)",
|
||||
OperationID: "updateMapfixDescription",
|
||||
Body: request,
|
||||
RawBody: rawBody,
|
||||
Params: middleware.Parameters{
|
||||
{
|
||||
Name: "MapfixID",
|
||||
In: "path",
|
||||
}: params.MapfixID,
|
||||
},
|
||||
Raw: r,
|
||||
}
|
||||
|
||||
type (
|
||||
Request = UpdateMapfixDescriptionReq
|
||||
Params = UpdateMapfixDescriptionParams
|
||||
Response = *UpdateMapfixDescriptionNoContent
|
||||
)
|
||||
response, err = middleware.HookMiddleware[
|
||||
Request,
|
||||
Params,
|
||||
Response,
|
||||
](
|
||||
m,
|
||||
mreq,
|
||||
unpackUpdateMapfixDescriptionParams,
|
||||
func(ctx context.Context, request Request, params Params) (response Response, err error) {
|
||||
err = s.h.UpdateMapfixDescription(ctx, request, params)
|
||||
return response, err
|
||||
},
|
||||
)
|
||||
} else {
|
||||
err = s.h.UpdateMapfixDescription(ctx, request, params)
|
||||
}
|
||||
if err != nil {
|
||||
if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
|
||||
if err := encodeErrorResponse(errRes, w, span); err != nil {
|
||||
defer recordError("Internal", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ht.ErrNotImplemented) {
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil {
|
||||
defer recordError("Internal", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := encodeUpdateMapfixDescriptionResponse(response, w, span); err != nil {
|
||||
defer recordError("EncodeResponse", err)
|
||||
if !errors.Is(err, ht.ErrInternalServerErrorResponse) {
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpdateMapfixModelRequest handles updateMapfixModel operation.
|
||||
//
|
||||
// Update model following role restrictions.
|
||||
|
||||
@@ -65,6 +65,7 @@ const (
|
||||
SessionValidateOperation OperationName = "SessionValidate"
|
||||
SetMapfixCompletedOperation OperationName = "SetMapfixCompleted"
|
||||
SetSubmissionCompletedOperation OperationName = "SetSubmissionCompleted"
|
||||
UpdateMapfixDescriptionOperation OperationName = "UpdateMapfixDescription"
|
||||
UpdateMapfixModelOperation OperationName = "UpdateMapfixModel"
|
||||
UpdateScriptOperation OperationName = "UpdateScript"
|
||||
UpdateScriptPolicyOperation OperationName = "UpdateScriptPolicy"
|
||||
|
||||
@@ -6818,6 +6818,90 @@ func decodeSetSubmissionCompletedParams(args [1]string, argsEscaped bool, r *htt
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// UpdateMapfixDescriptionParams is parameters of updateMapfixDescription operation.
|
||||
type UpdateMapfixDescriptionParams struct {
|
||||
// The unique identifier for a mapfix.
|
||||
MapfixID int64
|
||||
}
|
||||
|
||||
func unpackUpdateMapfixDescriptionParams(packed middleware.Parameters) (params UpdateMapfixDescriptionParams) {
|
||||
{
|
||||
key := middleware.ParameterKey{
|
||||
Name: "MapfixID",
|
||||
In: "path",
|
||||
}
|
||||
params.MapfixID = packed[key].(int64)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func decodeUpdateMapfixDescriptionParams(args [1]string, argsEscaped bool, r *http.Request) (params UpdateMapfixDescriptionParams, _ error) {
|
||||
// Decode path: MapfixID.
|
||||
if err := func() error {
|
||||
param := args[0]
|
||||
if argsEscaped {
|
||||
unescaped, err := url.PathUnescape(args[0])
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unescape path")
|
||||
}
|
||||
param = unescaped
|
||||
}
|
||||
if len(param) > 0 {
|
||||
d := uri.NewPathDecoder(uri.PathDecoderConfig{
|
||||
Param: "MapfixID",
|
||||
Value: param,
|
||||
Style: uri.PathStyleSimple,
|
||||
Explode: false,
|
||||
})
|
||||
|
||||
if err := func() error {
|
||||
val, err := d.DecodeValue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := conv.ToInt64(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params.MapfixID = c
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := func() error {
|
||||
if err := (validate.Int{
|
||||
MinSet: true,
|
||||
Min: 0,
|
||||
MaxSet: false,
|
||||
Max: 0,
|
||||
MinExclusive: false,
|
||||
MaxExclusive: false,
|
||||
MultipleOfSet: false,
|
||||
MultipleOf: 0,
|
||||
Pattern: nil,
|
||||
}).Validate(int64(params.MapfixID)); err != nil {
|
||||
return errors.Wrap(err, "int")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return validate.ErrFieldRequired
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return params, &ogenerrors.DecodeParamError{
|
||||
Name: "MapfixID",
|
||||
In: "path",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// UpdateMapfixModelParams is parameters of updateMapfixModel operation.
|
||||
type UpdateMapfixModelParams struct {
|
||||
// The unique identifier for a mapfix.
|
||||
|
||||
@@ -829,6 +829,41 @@ func (s *Server) decodeReleaseSubmissionsRequest(r *http.Request) (
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeUpdateMapfixDescriptionRequest(r *http.Request) (
|
||||
req UpdateMapfixDescriptionReq,
|
||||
rawBody []byte,
|
||||
close func() error,
|
||||
rerr error,
|
||||
) {
|
||||
var closers []func() error
|
||||
close = func() error {
|
||||
var merr error
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return req, rawBody, close, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "text/plain":
|
||||
reader := r.Body
|
||||
request := UpdateMapfixDescriptionReq{Data: reader}
|
||||
return request, rawBody, close, nil
|
||||
default:
|
||||
return req, rawBody, close, validate.InvalidContentType(ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) decodeUpdateScriptRequest(r *http.Request) (
|
||||
req *ScriptUpdate,
|
||||
rawBody []byte,
|
||||
|
||||
@@ -160,6 +160,16 @@ func encodeReleaseSubmissionsRequest(
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeUpdateMapfixDescriptionRequest(
|
||||
req UpdateMapfixDescriptionReq,
|
||||
r *http.Request,
|
||||
) error {
|
||||
const contentType = "text/plain"
|
||||
body := req
|
||||
ht.SetBody(r, body, contentType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeUpdateScriptRequest(
|
||||
req *ScriptUpdate,
|
||||
r *http.Request,
|
||||
|
||||
@@ -4808,6 +4808,66 @@ func decodeSetSubmissionCompletedResponse(resp *http.Response) (res *SetSubmissi
|
||||
return res, errors.Wrap(defRes, "error")
|
||||
}
|
||||
|
||||
func decodeUpdateMapfixDescriptionResponse(resp *http.Response) (res *UpdateMapfixDescriptionNoContent, _ error) {
|
||||
switch resp.StatusCode {
|
||||
case 204:
|
||||
// Code 204.
|
||||
return &UpdateMapfixDescriptionNoContent{}, nil
|
||||
}
|
||||
// Convenient error response.
|
||||
defRes, err := func() (res *ErrorStatusCode, err error) {
|
||||
ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var response Error
|
||||
if err := func() error {
|
||||
if err := response.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
// Validate response.
|
||||
if err := func() error {
|
||||
if err := response.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return res, errors.Wrap(err, "validate")
|
||||
}
|
||||
return &ErrorStatusCode{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: response,
|
||||
}, nil
|
||||
default:
|
||||
return res, validate.InvalidContentType(ct)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode)
|
||||
}
|
||||
return res, errors.Wrap(defRes, "error")
|
||||
}
|
||||
|
||||
func decodeUpdateMapfixModelResponse(resp *http.Response) (res *UpdateMapfixModelNoContent, _ error) {
|
||||
switch resp.StatusCode {
|
||||
case 204:
|
||||
|
||||
@@ -677,6 +677,13 @@ func encodeSetSubmissionCompletedResponse(response *SetSubmissionCompletedNoCont
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeUpdateMapfixDescriptionResponse(response *UpdateMapfixDescriptionNoContent, w http.ResponseWriter, span trace.Span) error {
|
||||
w.WriteHeader(204)
|
||||
span.SetStatus(codes.Ok, http.StatusText(204))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeUpdateMapfixModelResponse(response *UpdateMapfixModelNoContent, w http.ResponseWriter, span trace.Span) error {
|
||||
w.WriteHeader(204)
|
||||
span.SetStatus(codes.Ok, http.StatusText(204))
|
||||
|
||||
@@ -216,6 +216,28 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
case 'd': // Prefix: "description"
|
||||
|
||||
if l := len("description"); len(elem) >= l && elem[0:l] == "description" {
|
||||
elem = elem[l:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(elem) == 0 {
|
||||
// Leaf node.
|
||||
switch r.Method {
|
||||
case "PATCH":
|
||||
s.handleUpdateMapfixDescriptionRequest([1]string{
|
||||
args[0],
|
||||
}, elemIsEscaped, w, r)
|
||||
default:
|
||||
s.notAllowed(w, r, "PATCH")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'm': // Prefix: "model"
|
||||
|
||||
if l := len("model"); len(elem) >= l && elem[0:l] == "model" {
|
||||
@@ -1894,6 +1916,31 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) {
|
||||
|
||||
}
|
||||
|
||||
case 'd': // Prefix: "description"
|
||||
|
||||
if l := len("description"); len(elem) >= l && elem[0:l] == "description" {
|
||||
elem = elem[l:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(elem) == 0 {
|
||||
// Leaf node.
|
||||
switch method {
|
||||
case "PATCH":
|
||||
r.name = UpdateMapfixDescriptionOperation
|
||||
r.summary = "Update description (submitter only)"
|
||||
r.operationID = "updateMapfixDescription"
|
||||
r.operationGroup = ""
|
||||
r.pathPattern = "/mapfixes/{MapfixID}/description"
|
||||
r.args = args
|
||||
r.count = 1
|
||||
return r, true
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case 'm': // Prefix: "model"
|
||||
|
||||
if l := len("model"); len(elem) >= l && elem[0:l] == "model" {
|
||||
|
||||
@@ -2308,6 +2308,23 @@ func (s *Submissions) SetSubmissions(val []Submission) {
|
||||
s.Submissions = val
|
||||
}
|
||||
|
||||
// UpdateMapfixDescriptionNoContent is response for UpdateMapfixDescription operation.
|
||||
type UpdateMapfixDescriptionNoContent struct{}
|
||||
|
||||
type UpdateMapfixDescriptionReq struct {
|
||||
Data io.Reader
|
||||
}
|
||||
|
||||
// Read reads data from the Data reader.
|
||||
//
|
||||
// Kept to satisfy the io.Reader interface.
|
||||
func (s UpdateMapfixDescriptionReq) Read(p []byte) (n int, err error) {
|
||||
if s.Data == nil {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return s.Data.Read(p)
|
||||
}
|
||||
|
||||
// UpdateMapfixModelNoContent is response for UpdateMapfixModel operation.
|
||||
type UpdateMapfixModelNoContent struct{}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ var operationRolesCookieAuth = map[string][]string{
|
||||
SessionValidateOperation: []string{},
|
||||
SetMapfixCompletedOperation: []string{},
|
||||
SetSubmissionCompletedOperation: []string{},
|
||||
UpdateMapfixDescriptionOperation: []string{},
|
||||
UpdateMapfixModelOperation: []string{},
|
||||
UpdateScriptOperation: []string{},
|
||||
UpdateScriptPolicyOperation: []string{},
|
||||
|
||||
@@ -365,6 +365,12 @@ type Handler interface {
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/completed
|
||||
SetSubmissionCompleted(ctx context.Context, params SetSubmissionCompletedParams) error
|
||||
// UpdateMapfixDescription implements updateMapfixDescription operation.
|
||||
//
|
||||
// Update description (submitter only).
|
||||
//
|
||||
// PATCH /mapfixes/{MapfixID}/description
|
||||
UpdateMapfixDescription(ctx context.Context, req UpdateMapfixDescriptionReq, params UpdateMapfixDescriptionParams) error
|
||||
// UpdateMapfixModel implements updateMapfixModel operation.
|
||||
//
|
||||
// Update model following role restrictions.
|
||||
|
||||
@@ -547,6 +547,15 @@ func (UnimplementedHandler) SetSubmissionCompleted(ctx context.Context, params S
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// UpdateMapfixDescription implements updateMapfixDescription operation.
|
||||
//
|
||||
// Update description (submitter only).
|
||||
//
|
||||
// PATCH /mapfixes/{MapfixID}/description
|
||||
func (UnimplementedHandler) UpdateMapfixDescription(ctx context.Context, req UpdateMapfixDescriptionReq, params UpdateMapfixDescriptionParams) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// UpdateMapfixModel implements updateMapfixModel operation.
|
||||
//
|
||||
// Update model following role restrictions.
|
||||
|
||||
@@ -327,6 +327,48 @@ func (svc *Service) UpdateMapfixModel(ctx context.Context, params api.UpdateMapf
|
||||
)
|
||||
}
|
||||
|
||||
// UpdateMapfixDescription implements updateMapfixDescription operation.
|
||||
//
|
||||
// Update description (submitter only, status ChangesRequested or UnderConstruction).
|
||||
//
|
||||
// PATCH /mapfixes/{MapfixID}/description
|
||||
func (svc *Service) UpdateMapfixDescription(ctx context.Context, req api.UpdateMapfixDescriptionReq, params api.UpdateMapfixDescriptionParams) error {
|
||||
userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
|
||||
if !ok {
|
||||
return ErrUserInfo
|
||||
}
|
||||
|
||||
// read mapfix
|
||||
mapfix, err := svc.inner.GetMapfix(ctx, params.MapfixID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userId, err := userInfo.GetUserID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if caller is the submitter
|
||||
if userId != mapfix.Submitter {
|
||||
return ErrPermissionDeniedNotSubmitter
|
||||
}
|
||||
|
||||
// read the new description from request body
|
||||
data, err := io.ReadAll(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newDescription := string(data)
|
||||
|
||||
// check if Status is ChangesRequested or UnderConstruction
|
||||
update := service.NewMapfixUpdate()
|
||||
update.SetDescription(newDescription)
|
||||
allow_statuses := []model.MapfixStatus{model.MapfixStatusChangesRequested, model.MapfixStatusUnderConstruction}
|
||||
return svc.inner.UpdateMapfixIfStatus(ctx, params.MapfixID, allow_statuses, update)
|
||||
}
|
||||
|
||||
// ActionMapfixReject invokes actionMapfixReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
|
||||
@@ -20,7 +20,12 @@ export default function AuditEventItem({ event, validatorUser }: AuditEventItemP
|
||||
const { thumbnailUrl, isLoading } = useUserThumbnail(isValidator ? undefined : event.User, '150x150');
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
borderRadius: 1
|
||||
}}>
|
||||
<Box sx={{ position: 'relative', width: 40, height: 40 }}>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
|
||||
@@ -4,10 +4,22 @@ import {
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
keyframes
|
||||
} from "@mui/material";
|
||||
import CommentsTabPanel from './CommentsTabPanel';
|
||||
import AuditEventsTabPanel from './AuditEventsTabPanel';
|
||||
import { AuditEvent } from "@/app/ts/AuditEvent";
|
||||
import { AuditEvent, AuditEventType } from "@/app/ts/AuditEvent";
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
`;
|
||||
|
||||
interface CommentsAndAuditSectionProps {
|
||||
auditEvents: AuditEvent[];
|
||||
@@ -16,6 +28,7 @@ interface CommentsAndAuditSectionProps {
|
||||
handleCommentSubmit: () => void;
|
||||
validatorUser: number;
|
||||
userId: number | null;
|
||||
currentStatus?: number;
|
||||
}
|
||||
|
||||
export default function CommentsAndAuditSection({
|
||||
@@ -25,6 +38,7 @@ export default function CommentsAndAuditSection({
|
||||
handleCommentSubmit,
|
||||
validatorUser,
|
||||
userId,
|
||||
currentStatus,
|
||||
}: CommentsAndAuditSectionProps) {
|
||||
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
@@ -32,6 +46,16 @@ export default function CommentsAndAuditSection({
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
// Check if there's validator feedback for changes requested status
|
||||
// Show badge if status is ChangesRequested and there are validator events
|
||||
const hasValidatorFeedback = currentStatus === 1 && auditEvents.some(event =>
|
||||
event.User === validatorUser &&
|
||||
(
|
||||
event.EventType === AuditEventType.Error ||
|
||||
event.EventType === AuditEventType.CheckList
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 3 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
@@ -41,7 +65,24 @@ export default function CommentsAndAuditSection({
|
||||
aria-label="comments and audit tabs"
|
||||
>
|
||||
<Tab label="Comments" />
|
||||
<Tab label="Audit Events" />
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
Audit Events
|
||||
{hasValidatorFeedback && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ff9800',
|
||||
animation: `${pulse} 2s ease-in-out infinite`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Button, Stack } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, Typography, Box } from '@mui/material';
|
||||
import {MapfixInfo } from "@/app/ts/Mapfix";
|
||||
import {hasRole, Roles, RolesConstants} from "@/app/ts/Roles";
|
||||
import {SubmissionInfo} from "@/app/ts/Submission";
|
||||
import {Status, StatusMatches} from "@/app/ts/Status";
|
||||
|
||||
interface ReviewAction {
|
||||
name: string,
|
||||
action: string,
|
||||
name: string;
|
||||
action: string;
|
||||
confirmTitle?: string;
|
||||
confirmMessage?: string;
|
||||
requiresConfirmation: boolean;
|
||||
}
|
||||
|
||||
interface ReviewButtonsProps {
|
||||
@@ -19,20 +22,102 @@ interface ReviewButtonsProps {
|
||||
}
|
||||
|
||||
const ReviewActions = {
|
||||
Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction,
|
||||
AdminSubmit: {name:"Admin Submit",action:"trigger-submit"} as ReviewAction,
|
||||
SubmitUnchecked: {name:"Submit Unchecked", action:"trigger-submit-unchecked"} as ReviewAction,
|
||||
ResetSubmitting: {name:"Reset Submitting",action:"reset-submitting"} as ReviewAction,
|
||||
Revoke: {name:"Revoke",action:"revoke"} as ReviewAction,
|
||||
Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction,
|
||||
Reject: {name:"Reject",action:"reject"} as ReviewAction,
|
||||
Validate: {name:"Validate",action:"retry-validate"} as ReviewAction,
|
||||
ResetValidating: {name:"Reset Validating",action:"reset-validating"} as ReviewAction,
|
||||
RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction,
|
||||
Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction,
|
||||
ResetUploading: {name:"Reset Uploading",action:"reset-uploading"} as ReviewAction,
|
||||
Release: {name:"Release",action:"trigger-release"} as ReviewAction,
|
||||
ResetReleasing: {name:"Reset Releasing",action:"reset-releasing"} as ReviewAction,
|
||||
Submit: {
|
||||
name: "Submit for Review",
|
||||
action: "trigger-submit",
|
||||
confirmTitle: "Submit for Review",
|
||||
confirmMessage: "Are you ready to submit this for review? The model version is locked in once submitted, but you can revoke it later if needed.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
AdminSubmit: {
|
||||
name: "Submit on Behalf of User",
|
||||
action: "trigger-submit",
|
||||
confirmTitle: "Admin Submit",
|
||||
confirmMessage: "This will submit the work as if the original user did it. Continue?",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
SubmitUnchecked: {
|
||||
name: "Approve Without Validation",
|
||||
action: "trigger-submit-unchecked",
|
||||
confirmTitle: "Skip Validation",
|
||||
confirmMessage: "This will approve without running validation checks. Only use this if you're certain the work is correct.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
ResetSubmitting: {
|
||||
name: "Reset Submit Process",
|
||||
action: "reset-submitting",
|
||||
confirmTitle: "Reset Submit",
|
||||
confirmMessage: "This will force-cancel the submission process and return to 'Under Construction' status. Only use this if you're certain the backend encountered a catastrophic failure. Misuse will corrupt the workflow.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
Revoke: {
|
||||
name: "Revoke",
|
||||
action: "revoke",
|
||||
confirmTitle: "Revoke",
|
||||
confirmMessage: "This will withdraw from review and return to 'Under Construction' status.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
Accept: {
|
||||
name: "Accept & Validate",
|
||||
action: "trigger-validate",
|
||||
confirmTitle: "Accept",
|
||||
confirmMessage: "This will accept and trigger validation. The work will proceed to the next stage.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
Reject: {
|
||||
name: "Reject",
|
||||
action: "reject",
|
||||
confirmTitle: "Reject",
|
||||
confirmMessage: "This will permanently reject. The user will need to create a new one. Are you sure?",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
Validate: {
|
||||
name: "Run Validation",
|
||||
action: "retry-validate",
|
||||
requiresConfirmation: false
|
||||
} as ReviewAction,
|
||||
ResetValidating: {
|
||||
name: "Reset Validation Process",
|
||||
action: "reset-validating",
|
||||
confirmTitle: "Reset Validation",
|
||||
confirmMessage: "This will force-abort the validation process so you can retry. Only use this if you're certain the backend encountered a catastrophic failure. Misuse will corrupt the workflow.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
RequestChanges: {
|
||||
name: "Request Changes",
|
||||
action: "request-changes",
|
||||
confirmTitle: "Request Changes",
|
||||
confirmMessage: "Request that the submitter make changes. Make sure you've explained which changes are requested in a comment.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
Upload: {
|
||||
name: "Upload to Roblox",
|
||||
action: "trigger-upload",
|
||||
confirmTitle: "Upload to Roblox Group",
|
||||
confirmMessage: "This will upload the validated work to the Roblox group. Continue?",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
ResetUploading: {
|
||||
name: "Reset Upload Process",
|
||||
action: "reset-uploading",
|
||||
confirmTitle: "Reset Upload",
|
||||
confirmMessage: "This will force-abort the upload to Roblox so you can retry. Only use this if you're certain the backend encountered a catastrophic failure. Misuse will corrupt the workflow.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
Release: {
|
||||
name: "Release to Game",
|
||||
action: "trigger-release",
|
||||
confirmTitle: "Release to Game",
|
||||
confirmMessage: "This will make the work available in game. This is the final step!",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
ResetReleasing: {
|
||||
name: "Reset Release Process",
|
||||
action: "reset-releasing",
|
||||
confirmTitle: "Reset Release",
|
||||
confirmMessage: "This will force-abort the release to the game so you can retry. Only use this if you're certain the backend encountered a catastrophic failure. Misuse will corrupt the workflow.",
|
||||
requiresConfirmation: true
|
||||
} as ReviewAction,
|
||||
}
|
||||
|
||||
const ReviewButtons: React.FC<ReviewButtonsProps> = ({
|
||||
@@ -42,16 +127,46 @@ const ReviewButtons: React.FC<ReviewButtonsProps> = ({
|
||||
roles,
|
||||
type,
|
||||
}) => {
|
||||
const getVisibleButtons = () => {
|
||||
if (!item || userId === null) return [];
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
open: boolean;
|
||||
action: ReviewAction | null;
|
||||
}>({ open: false, action: null });
|
||||
|
||||
const handleButtonClick = (action: ReviewAction) => {
|
||||
if (action.requiresConfirmation) {
|
||||
setConfirmDialog({ open: true, action });
|
||||
} else {
|
||||
onClick(action.action, item.ID);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (confirmDialog.action) {
|
||||
onClick(confirmDialog.action.action, item.ID);
|
||||
}
|
||||
setConfirmDialog({ open: false, action: null });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setConfirmDialog({ open: false, action: null });
|
||||
};
|
||||
|
||||
const getVisibleButtons = () => {
|
||||
if (!item || userId === null) return { primary: [], secondary: [], submitter: [], reviewer: [], admin: [] };
|
||||
|
||||
// Define a type for the button
|
||||
type ReviewButton = {
|
||||
action: ReviewAction;
|
||||
color: "primary" | "error" | "success" | "info" | "warning";
|
||||
variant?: "contained" | "outlined";
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
|
||||
const buttons: ReviewButton[] = [];
|
||||
const primaryButtons: ReviewButton[] = [];
|
||||
const secondaryButtons: ReviewButton[] = [];
|
||||
const submitterButtons: ReviewButton[] = [];
|
||||
const reviewerButtons: ReviewButton[] = [];
|
||||
const adminButtons: ReviewButton[] = [];
|
||||
|
||||
const is_submitter = userId === item.Submitter;
|
||||
const status = item.StatusID;
|
||||
|
||||
@@ -59,133 +174,215 @@ const ReviewButtons: React.FC<ReviewButtonsProps> = ({
|
||||
const uploadRole = type === "submission" ? RolesConstants.SubmissionUpload : RolesConstants.MapfixUpload;
|
||||
const releaseRole = type === "submission" ? RolesConstants.SubmissionRelease : RolesConstants.MapfixRelease;
|
||||
|
||||
// Submitter actions
|
||||
if (is_submitter) {
|
||||
if (StatusMatches(status, [Status.UnderConstruction, Status.ChangesRequested])) {
|
||||
buttons.push({
|
||||
submitterButtons.push({
|
||||
action: ReviewActions.Submit,
|
||||
color: "primary"
|
||||
color: "success"
|
||||
});
|
||||
}
|
||||
|
||||
if (StatusMatches(status, [Status.Submitted, Status.ChangesRequested])) {
|
||||
buttons.push({
|
||||
submitterButtons.push({
|
||||
action: ReviewActions.Revoke,
|
||||
color: "error"
|
||||
color: "warning",
|
||||
variant: "outlined"
|
||||
});
|
||||
}
|
||||
|
||||
if (status === Status.Submitting) {
|
||||
buttons.push({
|
||||
adminButtons.push({
|
||||
action: ReviewActions.ResetSubmitting,
|
||||
color: "warning"
|
||||
color: "error",
|
||||
variant: "outlined"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons for review role
|
||||
// Reviewer actions
|
||||
if (hasRole(roles, reviewRole)) {
|
||||
if (status === Status.Submitted && !is_submitter) {
|
||||
buttons.push(
|
||||
{
|
||||
action: ReviewActions.Accept,
|
||||
color: "success"
|
||||
},
|
||||
{
|
||||
action: ReviewActions.Reject,
|
||||
color: "error"
|
||||
}
|
||||
);
|
||||
reviewerButtons.push({
|
||||
action: ReviewActions.Accept,
|
||||
color: "success"
|
||||
});
|
||||
reviewerButtons.push({
|
||||
action: ReviewActions.Reject,
|
||||
color: "error",
|
||||
variant: "outlined"
|
||||
});
|
||||
}
|
||||
|
||||
if (status === Status.AcceptedUnvalidated) {
|
||||
buttons.push({
|
||||
reviewerButtons.push({
|
||||
action: ReviewActions.Validate,
|
||||
color: "info"
|
||||
color: "primary"
|
||||
});
|
||||
}
|
||||
|
||||
if (status === Status.Validating) {
|
||||
buttons.push({
|
||||
adminButtons.push({
|
||||
action: ReviewActions.ResetValidating,
|
||||
color: "warning"
|
||||
color: "error",
|
||||
variant: "outlined"
|
||||
});
|
||||
}
|
||||
|
||||
if (StatusMatches(status, [Status.Validated, Status.AcceptedUnvalidated, Status.Submitted]) && !is_submitter) {
|
||||
buttons.push({
|
||||
reviewerButtons.push({
|
||||
action: ReviewActions.RequestChanges,
|
||||
color: "warning"
|
||||
color: "warning",
|
||||
variant: "outlined"
|
||||
});
|
||||
}
|
||||
|
||||
if (status === Status.ChangesRequested) {
|
||||
buttons.push({
|
||||
adminButtons.push({
|
||||
action: ReviewActions.SubmitUnchecked,
|
||||
color: "warning"
|
||||
color: "warning",
|
||||
variant: "outlined"
|
||||
});
|
||||
// button only exists for submissions
|
||||
// submitter has normal submit button
|
||||
if (type === "submission" && !is_submitter) {
|
||||
buttons.push({
|
||||
adminButtons.push({
|
||||
action: ReviewActions.AdminSubmit,
|
||||
color: "primary"
|
||||
color: "info",
|
||||
variant: "outlined"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons for upload role
|
||||
// Upload role actions
|
||||
if (hasRole(roles, uploadRole)) {
|
||||
if (status === Status.Validated) {
|
||||
buttons.push({
|
||||
reviewerButtons.push({
|
||||
action: ReviewActions.Upload,
|
||||
color: "success"
|
||||
});
|
||||
}
|
||||
|
||||
if (status === Status.Uploading) {
|
||||
buttons.push({
|
||||
adminButtons.push({
|
||||
action: ReviewActions.ResetUploading,
|
||||
color: "warning"
|
||||
color: "error",
|
||||
variant: "outlined"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons for release role
|
||||
// Release role actions
|
||||
if (hasRole(roles, releaseRole)) {
|
||||
// submissions do not have a release button
|
||||
if (type === "mapfix" && status === Status.Uploaded) {
|
||||
buttons.push({
|
||||
reviewerButtons.push({
|
||||
action: ReviewActions.Release,
|
||||
color: "success"
|
||||
});
|
||||
}
|
||||
|
||||
if (status === Status.Releasing) {
|
||||
buttons.push({
|
||||
adminButtons.push({
|
||||
action: ReviewActions.ResetReleasing,
|
||||
color: "warning"
|
||||
color: "error",
|
||||
variant: "outlined"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return buttons;
|
||||
return {
|
||||
primary: primaryButtons,
|
||||
secondary: secondaryButtons,
|
||||
submitter: submitterButtons,
|
||||
reviewer: reviewerButtons,
|
||||
admin: adminButtons
|
||||
};
|
||||
};
|
||||
|
||||
const buttons = getVisibleButtons();
|
||||
const hasAnyButtons = buttons.submitter.length > 0 || buttons.reviewer.length > 0 || buttons.admin.length > 0;
|
||||
|
||||
if (!hasAnyButtons) return null;
|
||||
|
||||
const ActionCard = ({ title, actions, isFirst = false }: { title: string; actions: any[]; isFirst?: boolean }) => {
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: isFirst ? 0 : 3 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
mb: 1.5,
|
||||
display: 'block'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{actions.map((button, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="contained"
|
||||
color={button.color}
|
||||
fullWidth
|
||||
size="large"
|
||||
onClick={() => handleButtonClick(button.action)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
py: 1.5
|
||||
}}
|
||||
>
|
||||
{button.action.name}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{getVisibleButtons().map((button, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="contained"
|
||||
color={button.color}
|
||||
fullWidth
|
||||
onClick={() => onClick(button.action.action, item.ID)}
|
||||
>
|
||||
{button.action.name}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
<>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<ActionCard title="Your Actions" actions={buttons.submitter} isFirst={true} />
|
||||
<ActionCard title="Review Actions" actions={buttons.reviewer} isFirst={buttons.submitter.length === 0} />
|
||||
<ActionCard title="Admin Actions" actions={buttons.admin} isFirst={buttons.submitter.length === 0 && buttons.reviewer.length === 0} />
|
||||
</Box>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={confirmDialog.open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
{confirmDialog.action?.confirmTitle || confirmDialog.action?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{confirmDialog.action?.confirmMessage || "Are you sure you want to proceed?"}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleCancel} color="inherit">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
autoFocus
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Paper, Grid, Typography } from "@mui/material";
|
||||
import { Paper, Grid, Typography, TextField, IconButton, Box } from "@mui/material";
|
||||
import { ReviewItemHeader } from "./ReviewItemHeader";
|
||||
import { CopyableField } from "@/app/_components/review/CopyableField";
|
||||
import WorkflowStepper from "./WorkflowStepper";
|
||||
import { SubmissionInfo } from "@/app/ts/Submission";
|
||||
import { MapfixInfo } from "@/app/ts/Mapfix";
|
||||
import { getGameName } from "@/app/utils/games";
|
||||
import { useState } from "react";
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { Status, StatusMatches } from "@/app/ts/Status";
|
||||
|
||||
// Define a field configuration for specific types
|
||||
interface FieldConfig {
|
||||
@@ -18,12 +23,24 @@ type ReviewItemType = SubmissionInfo | MapfixInfo;
|
||||
interface ReviewItemProps {
|
||||
item: ReviewItemType;
|
||||
handleCopyValue: (value: string) => void;
|
||||
currentUserId?: number;
|
||||
userId?: number | null;
|
||||
onDescriptionUpdate?: () => Promise<void>;
|
||||
showSnackbar?: (message: string, severity?: 'success' | 'error' | 'info' | 'warning') => void;
|
||||
}
|
||||
|
||||
export function ReviewItem({
|
||||
item,
|
||||
handleCopyValue
|
||||
handleCopyValue,
|
||||
currentUserId,
|
||||
userId,
|
||||
onDescriptionUpdate,
|
||||
showSnackbar
|
||||
}: ReviewItemProps) {
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [editedDescription, setEditedDescription] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Type guard to check if item is valid
|
||||
if (!item) return null;
|
||||
|
||||
@@ -31,6 +48,57 @@ export function ReviewItem({
|
||||
const isSubmission = 'UploadedAssetID' in item;
|
||||
const isMapfix = 'TargetAssetID' in item;
|
||||
|
||||
// Check if current user is the submitter
|
||||
const isSubmitter = userId !== null && userId === item.Submitter;
|
||||
|
||||
// Check if description can be edited (only in ChangesRequested or UnderConstruction status)
|
||||
const canEditDescription = isSubmitter && isMapfix && StatusMatches(item.StatusID, [Status.ChangesRequested, Status.UnderConstruction]);
|
||||
|
||||
const handleEditClick = () => {
|
||||
setEditedDescription(isMapfix ? (item.Description || "") : "");
|
||||
setIsEditingDescription(true);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription("");
|
||||
};
|
||||
|
||||
const handleSaveDescription = async () => {
|
||||
if (!isMapfix) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/v1/mapfixes/${item.ID}/description`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: editedDescription,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update description: ${response.status}`);
|
||||
}
|
||||
|
||||
setIsEditingDescription(false);
|
||||
if (showSnackbar) {
|
||||
showSnackbar("Description updated successfully", "success");
|
||||
}
|
||||
if (onDescriptionUpdate) {
|
||||
await onDescriptionUpdate();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating description:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to update description";
|
||||
if (showSnackbar) {
|
||||
showSnackbar(errorMessage, "error");
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Define static fields based on item type
|
||||
let fields: FieldConfig[] = [];
|
||||
if (isSubmission) {
|
||||
@@ -88,14 +156,59 @@ export function ReviewItem({
|
||||
</Grid>
|
||||
|
||||
{/* Description Section */}
|
||||
{isMapfix && item.Description && (
|
||||
{isMapfix && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
Description
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{item.Description}
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Description
|
||||
</Typography>
|
||||
{canEditDescription && !isEditingDescription && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleEditClick}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="edit description"
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{isEditingDescription ? (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
placeholder="Describe the changes made in this mapfix"
|
||||
slotProps={{ htmlInput: { maxLength: 256 } }}
|
||||
helperText={`${editedDescription.length}/256 characters`}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Box display="flex" gap={1} mt={1}>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={handleSaveDescription}
|
||||
disabled={isSaving}
|
||||
aria-label="save description"
|
||||
>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleCancelEdit}
|
||||
disabled={isSaving}
|
||||
aria-label="cancel edit"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body1">
|
||||
{item.Description || "No description provided"}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Paper>
|
||||
@@ -105,6 +218,8 @@ export function ReviewItem({
|
||||
<WorkflowStepper
|
||||
currentStatus={item.StatusID}
|
||||
type={isMapfix ? 'mapfix' : 'submission'}
|
||||
submitterId={item.Submitter}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
</Paper>
|
||||
</>
|
||||
|
||||
@@ -50,7 +50,7 @@ interface ReviewItemHeaderProps {
|
||||
}
|
||||
|
||||
export const ReviewItemHeader = ({ displayName, assetId, statusId, creator, submitterId }: ReviewItemHeaderProps) => {
|
||||
const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
|
||||
const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting, Status.Releasing]);
|
||||
const { thumbnailUrl, isLoading } = useUserThumbnail(submitterId, '150x150');
|
||||
const pulse = keyframes`
|
||||
0%, 100% { opacity: 0.2; transform: scale(0.8); }
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Stepper, Step, StepLabel, Box, StepConnector, stepConnectorClasses, StepIconProps, styled, keyframes } from '@mui/material';
|
||||
import { Stepper, Step, StepLabel, Box, StepConnector, stepConnectorClasses, StepIconProps, styled, keyframes, Typography, Paper } from '@mui/material';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PendingIcon from '@mui/icons-material/Pending';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import { Status } from '@/app/ts/Status';
|
||||
|
||||
const pulse = keyframes`
|
||||
@@ -18,6 +19,8 @@ const pulse = keyframes`
|
||||
interface WorkflowStepperProps {
|
||||
currentStatus: number;
|
||||
type: 'submission' | 'mapfix';
|
||||
submitterId?: number;
|
||||
currentUserId?: number;
|
||||
}
|
||||
|
||||
// Define the workflow steps
|
||||
@@ -164,19 +167,49 @@ const CustomStepIcon = (props: StepIconProps & { isRejected?: boolean; isChanges
|
||||
);
|
||||
};
|
||||
|
||||
const WorkflowStepper: React.FC<WorkflowStepperProps> = ({ currentStatus, type }) => {
|
||||
const WorkflowStepper: React.FC<WorkflowStepperProps> = ({ currentStatus, type, submitterId, currentUserId }) => {
|
||||
const workflow = type === 'mapfix' ? mapfixWorkflow : submissionWorkflow;
|
||||
|
||||
// Check if rejected or released
|
||||
const isRejected = currentStatus === Status.Rejected;
|
||||
const isReleased = currentStatus === Status.Release || currentStatus === Status.Releasing;
|
||||
const isChangesRequested = currentStatus === Status.ChangesRequested;
|
||||
const isUnderConstruction = currentStatus === Status.UnderConstruction;
|
||||
|
||||
// Find the active step
|
||||
const activeStep = workflow.findIndex(step =>
|
||||
step.statuses.includes(currentStatus)
|
||||
);
|
||||
|
||||
// Determine nudge message
|
||||
const getNudgeContent = () => {
|
||||
if (isUnderConstruction) {
|
||||
return {
|
||||
icon: InfoOutlinedIcon,
|
||||
title: 'Not Yet Submitted',
|
||||
message: 'Your submission has been created but has not been submitted. Click "Submit" to submit it.',
|
||||
color: '#2196f3',
|
||||
bgColor: 'rgba(33, 150, 243, 0.08)'
|
||||
};
|
||||
}
|
||||
if (isChangesRequested) {
|
||||
return {
|
||||
icon: WarningIcon,
|
||||
title: 'Changes Requested',
|
||||
message: 'Review comments and audit events, make modifications, and submit again.',
|
||||
color: '#ff9800',
|
||||
bgColor: 'rgba(255, 152, 0, 0.08)'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const nudge = getNudgeContent();
|
||||
|
||||
// Only show nudge if current user is the submitter
|
||||
const isSubmitter = submitterId !== undefined && currentUserId !== undefined && submitterId === currentUserId;
|
||||
const shouldShowNudge = nudge && isSubmitter;
|
||||
|
||||
// If rejected, show all steps as incomplete with error state
|
||||
if (isRejected) {
|
||||
return (
|
||||
@@ -245,6 +278,36 @@ const WorkflowStepper: React.FC<WorkflowStepperProps> = ({ currentStatus, type }
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
|
||||
{/* Action Nudge */}
|
||||
{shouldShowNudge && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
borderLeft: 4,
|
||||
borderColor: nudge.color,
|
||||
backgroundColor: nudge.bgColor,
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
alignItems: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ color: nudge.color, display: 'flex', alignItems: 'center', pt: 0.25 }}>
|
||||
<nudge.icon sx={{ fontSize: 24 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ color: nudge.color, mb: 0.5 }}>
|
||||
{nudge.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontSize: '0.875rem' }}>
|
||||
{nudge.message}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,7 +100,7 @@ export function useReviewData({itemType, itemId}: UseReviewDataProps): UseReview
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
if (StatusMatches(data.StatusID, [Status.Uploading, Status.Submitting, Status.Validating])) {
|
||||
if (StatusMatches(data.StatusID, [Status.Uploading, Status.Submitting, Status.Validating, Status.Releasing])) {
|
||||
const intervalId = setInterval(() => {
|
||||
fetchData(true);
|
||||
}, 5000);
|
||||
|
||||
@@ -365,6 +365,10 @@ export default function MapfixDetailsPage() {
|
||||
<ReviewItem
|
||||
item={mapfix}
|
||||
handleCopyValue={handleCopyId}
|
||||
currentUserId={user ?? undefined}
|
||||
userId={user}
|
||||
onDescriptionUpdate={() => refreshData(true)}
|
||||
showSnackbar={showSnackbar}
|
||||
/>
|
||||
|
||||
{/* Comments Section */}
|
||||
@@ -375,6 +379,7 @@ export default function MapfixDetailsPage() {
|
||||
handleCommentSubmit={handleCommentSubmit}
|
||||
validatorUser={validatorUser}
|
||||
userId={user}
|
||||
currentStatus={mapfix.StatusID}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -267,6 +267,7 @@ export default function SubmissionDetailsPage() {
|
||||
<ReviewItem
|
||||
item={submission}
|
||||
handleCopyValue={handleCopyId}
|
||||
currentUserId={user ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Comments Section */}
|
||||
@@ -277,6 +278,7 @@ export default function SubmissionDetailsPage() {
|
||||
handleCommentSubmit={handleCommentSubmit}
|
||||
validatorUser={validatorUser}
|
||||
userId={user}
|
||||
currentStatus={submission.StatusID}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
Reference in New Issue
Block a user