add app review system with validation pipeline and admin htmx UI

This commit is contained in:
2026-01-18 21:35:43 +01:00
parent cf9f42b66d
commit fbcb5c9543
11 changed files with 1516 additions and 9 deletions

View File

@@ -878,3 +878,209 @@ func (db *DB) GetPublishedVersionByCode(ctx context.Context, packageID string, v
return scanVersion(row)
}
// VersionWithApp combines version data with its parent app data for review display
type VersionWithApp struct {
Version *AppVersion `json:"version"`
App *App `json:"app"`
DeveloperName string `json:"developer_name"`
DeveloperEmail string `json:"developer_email"`
}
// GetVersionsInReview returns versions pending review with pagination
func (db *DB) GetVersionsInReview(ctx context.Context, limit, offset int) ([]VersionWithApp, int, error) {
// Get total count
var total int
err := db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM app_versions WHERE status = 'in_review'
`).Scan(&total)
if err != nil {
return nil, 0, err
}
// Query versions with app and developer info
rows, err := db.QueryContext(ctx, `
SELECT v.id, v.app_id, v.version_code, v.version_name, v.package_url, v.package_size, v.signature,
v.permissions, v.min_mosis_version, v.release_notes, v.status, v.review_notes, v.published_at, v.created_at,
a.id, a.developer_id, a.package_id, a.name, a.description, a.category, a.tags, a.status, a.created_at, a.updated_at,
d.name, d.email
FROM app_versions v
JOIN apps a ON a.id = v.app_id
JOIN developers d ON d.id = a.developer_id
WHERE v.status = 'in_review'
ORDER BY v.created_at ASC
LIMIT ? OFFSET ?
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var results []VersionWithApp
for rows.Next() {
var vwa VersionWithApp
var v AppVersion
var app App
var vPackageURL, vSignature, vPermsJSON, vMinVersion, vReleaseNotes, vReviewNotes sql.NullString
var vPublishedAt, vCreatedAt sql.NullString
var vPackageSize sql.NullInt64
var aDesc, aCat, aTagsJSON sql.NullString
var aCreatedAt, aUpdatedAt string
err := rows.Scan(
&v.ID, &v.AppID, &v.VersionCode, &v.VersionName, &vPackageURL, &vPackageSize, &vSignature,
&vPermsJSON, &vMinVersion, &vReleaseNotes, &v.Status, &vReviewNotes, &vPublishedAt, &vCreatedAt,
&app.ID, &app.DeveloperID, &app.PackageID, &app.Name, &aDesc, &aCat, &aTagsJSON, &app.Status, &aCreatedAt, &aUpdatedAt,
&vwa.DeveloperName, &vwa.DeveloperEmail,
)
if err != nil {
continue
}
// Populate version
v.PackageURL = vPackageURL.String
v.PackageSize = vPackageSize.Int64
v.Signature = vSignature.String
v.MinMosisVersion = vMinVersion.String
v.ReleaseNotes = vReleaseNotes.String
v.ReviewNotes = vReviewNotes.String
v.Permissions = []string{}
if vPermsJSON.Valid && vPermsJSON.String != "" {
json.Unmarshal([]byte(vPermsJSON.String), &v.Permissions)
}
if vCreatedAt.Valid {
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", vCreatedAt.String)
}
if vPublishedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", vPublishedAt.String)
v.PublishedAt = &t
}
// Populate app
app.Description = aDesc.String
app.Category = aCat.String
app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aCreatedAt)
app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aUpdatedAt)
app.Tags = []string{}
if aTagsJSON.Valid && aTagsJSON.String != "" {
json.Unmarshal([]byte(aTagsJSON.String), &app.Tags)
}
vwa.Version = &v
vwa.App = &app
results = append(results, vwa)
}
return results, total, nil
}
// GetVersionWithApp retrieves a version with its app and developer info
func (db *DB) GetVersionWithApp(ctx context.Context, versionID string) (*VersionWithApp, error) {
row := db.QueryRowContext(ctx, `
SELECT v.id, v.app_id, v.version_code, v.version_name, v.package_url, v.package_size, v.signature,
v.permissions, v.min_mosis_version, v.release_notes, v.status, v.review_notes, v.published_at, v.created_at,
a.id, a.developer_id, a.package_id, a.name, a.description, a.category, a.tags, a.status, a.created_at, a.updated_at,
d.name, d.email
FROM app_versions v
JOIN apps a ON a.id = v.app_id
JOIN developers d ON d.id = a.developer_id
WHERE v.id = ?
`, versionID)
var vwa VersionWithApp
var v AppVersion
var app App
var vPackageURL, vSignature, vPermsJSON, vMinVersion, vReleaseNotes, vReviewNotes sql.NullString
var vPublishedAt, vCreatedAt sql.NullString
var vPackageSize sql.NullInt64
var aDesc, aCat, aTagsJSON sql.NullString
var aCreatedAt, aUpdatedAt string
err := row.Scan(
&v.ID, &v.AppID, &v.VersionCode, &v.VersionName, &vPackageURL, &vPackageSize, &vSignature,
&vPermsJSON, &vMinVersion, &vReleaseNotes, &v.Status, &vReviewNotes, &vPublishedAt, &vCreatedAt,
&app.ID, &app.DeveloperID, &app.PackageID, &app.Name, &aDesc, &aCat, &aTagsJSON, &app.Status, &aCreatedAt, &aUpdatedAt,
&vwa.DeveloperName, &vwa.DeveloperEmail,
)
if err != nil {
return nil, err
}
// Populate version
v.PackageURL = vPackageURL.String
v.PackageSize = vPackageSize.Int64
v.Signature = vSignature.String
v.MinMosisVersion = vMinVersion.String
v.ReleaseNotes = vReleaseNotes.String
v.ReviewNotes = vReviewNotes.String
v.Permissions = []string{}
if vPermsJSON.Valid && vPermsJSON.String != "" {
json.Unmarshal([]byte(vPermsJSON.String), &v.Permissions)
}
if vCreatedAt.Valid {
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", vCreatedAt.String)
}
if vPublishedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", vPublishedAt.String)
v.PublishedAt = &t
}
// Populate app
app.Description = aDesc.String
app.Category = aCat.String
app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aCreatedAt)
app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aUpdatedAt)
app.Tags = []string{}
if aTagsJSON.Valid && aTagsJSON.String != "" {
json.Unmarshal([]byte(aTagsJSON.String), &app.Tags)
}
vwa.Version = &v
vwa.App = &app
return &vwa, nil
}
// ApproveVersion approves a version and optionally publishes it
func (db *DB) ApproveVersion(ctx context.Context, versionID, reviewerNotes string) error {
_, err := db.ExecContext(ctx, `
UPDATE app_versions
SET status = 'published', review_notes = ?, published_at = datetime('now')
WHERE id = ?
`, reviewerNotes, versionID)
if err != nil {
return err
}
// Also update the app status to published
_, err = db.ExecContext(ctx, `
UPDATE apps SET status = 'published', updated_at = datetime('now')
WHERE id = (SELECT app_id FROM app_versions WHERE id = ?)
`, versionID)
return err
}
// RejectVersion rejects a version with feedback
func (db *DB) RejectVersion(ctx context.Context, versionID, reason, message string) error {
notes := reason
if message != "" {
notes = reason + ": " + message
}
_, err := db.ExecContext(ctx, `
UPDATE app_versions SET status = 'rejected', review_notes = ? WHERE id = ?
`, notes, versionID)
return err
}
// GetReviewStats returns statistics about the review queue
func (db *DB) GetReviewStats(ctx context.Context) (pending, approved, rejected int, err error) {
err = db.QueryRowContext(ctx, `
SELECT
COALESCE(SUM(CASE WHEN status = 'in_review' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0)
FROM app_versions
`).Scan(&pending, &approved, &rejected)
return
}