Skip to content

Commit a606685

Browse files
committed
feat: add App Store notifications and history
1 parent c9fdb73 commit a606685

6 files changed

Lines changed: 547 additions & 2 deletions

File tree

api/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,26 @@ X-Reviewer-Token: <REVIEWER_TOKEN>
5050

5151
Runnerは返された`validation_attempt_id`を成功・失敗reportへ含めます。処理中にRunnerが停止した場合はlease期限後に再取得されます。APIの`REVIEWER_TOKEN`とRunnerの`APPSTORE_REVIEWER_TOKEN`は同じ値を設定します。
5252

53+
## 通知と履歴
54+
55+
Release検証、審査、公開停止、Package停止・再開の通知は、append-onlyの`audit_logs`から生成します。通知本文を別テーブルへ複製しないため、通知機能追加前の過去の問題も表示できます。既読状態だけを`notification_reads`へAccount単位で保存します。
56+
57+
```text
58+
GET /v1/developer/notifications
59+
POST /v1/developer/notifications/{notification_id}/read
60+
POST /v1/developer/notifications/read-all
61+
GET /v1/developer/apps/{bundle_id}/history
62+
63+
GET /v1/admin/notifications
64+
POST /v1/admin/notifications/{notification_id}/read
65+
POST /v1/admin/notifications/read-all
66+
GET /v1/admin/releases/{release_id}/history
67+
```
68+
69+
Developer向けAPIは認証中のDeveloperに属するRelease/Packageだけを返し、運営者向けAPIは`ADMIN_TOKEN`と管理Account IDを要求します。既読状態はDeveloper単位ではなくAccount単位なので、同じDeveloperの各メンバーが個別に管理できます。
70+
71+
`download_failed``reviewer_internal_error`は運用障害として分類し、開発者へ再提出を要求しません。Package、署名、Certificate、GitHub assetの不整合は開発者の対応が必要な検証結果として表示します。
72+
5373
## ローカル確認
5474

5575
```powershell
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CREATE TABLE notification_reads (
2+
notification_id TEXT NOT NULL REFERENCES audit_logs(audit_id),
3+
account_id TEXT NOT NULL,
4+
read_at INTEGER NOT NULL,
5+
PRIMARY KEY (notification_id, account_id)
6+
);
7+
8+
CREATE INDEX idx_notification_reads_account
9+
ON notification_reads(account_id, read_at DESC);
10+
11+
CREATE INDEX idx_audit_logs_action_created
12+
ON audit_logs(action, created_at DESC);

api/src/lib.rs

Lines changed: 225 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ fn valid_version(value: &str) -> bool {
148148
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+'))
149149
}
150150

151+
fn valid_notification_id(value: &str) -> bool {
152+
value.starts_with("audit_")
153+
&& value.len() <= 160
154+
&& value
155+
.bytes()
156+
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
157+
}
158+
151159
fn github_repository(value: &str) -> Option<(&str, &str)> {
152160
let (owner, repository) = value.split_once('/')?;
153161
let valid = |part: &str, max: usize| {
@@ -828,6 +836,181 @@ async fn list_developer_releases(req: Request, ctx: RouteContext<()>) -> Result<
828836
json_response(&json!({"bundle_id":bundle_id,"releases":rows}), 200)
829837
}
830838

839+
async fn developer_notifications(req: Request, ctx: RouteContext<()>) -> Result<Response> {
840+
let actor = match require_developer_actor(&req, &ctx.env).await? {
841+
Ok(value) => value,
842+
Err(response) => return Ok(response),
843+
};
844+
let (limit, offset) = page(&req);
845+
let database = db(&ctx)?;
846+
let notifications = store::developer_notifications(
847+
&database,
848+
&actor.developer_id,
849+
&actor.account_id,
850+
limit,
851+
offset,
852+
)
853+
.await?;
854+
let unread_count =
855+
store::developer_unread_count(&database, &actor.developer_id, &actor.account_id).await?;
856+
json_response(
857+
&json!({
858+
"developer_id":actor.developer_id,
859+
"notifications":notifications,
860+
"unread_count":unread_count
861+
}),
862+
200,
863+
)
864+
}
865+
866+
async fn read_developer_notification(req: Request, ctx: RouteContext<()>) -> Result<Response> {
867+
if let Some(response) =
868+
rate_limited(&req, &ctx.env, "MUTATION_RATE_LIMITER", "notification-read").await?
869+
{
870+
return Ok(response);
871+
}
872+
let actor = match require_developer_actor(&req, &ctx.env).await? {
873+
Ok(value) => value,
874+
Err(response) => return Ok(response),
875+
};
876+
let notification_id = param(&ctx, "notification_id");
877+
if !valid_notification_id(notification_id) {
878+
return error("NOTIFICATION_ID_INVALID", "Notification ID is invalid", 422);
879+
}
880+
if !store::mark_developer_notification_read(
881+
&db(&ctx)?,
882+
notification_id,
883+
&actor.developer_id,
884+
&actor.account_id,
885+
now(),
886+
)
887+
.await?
888+
{
889+
return error("NOTIFICATION_NOT_FOUND", "Notification not found", 404);
890+
}
891+
Ok(Response::empty()?.with_status(204))
892+
}
893+
894+
async fn read_all_developer_notifications(req: Request, ctx: RouteContext<()>) -> Result<Response> {
895+
if let Some(response) = rate_limited(
896+
&req,
897+
&ctx.env,
898+
"MUTATION_RATE_LIMITER",
899+
"notifications-read-all",
900+
)
901+
.await?
902+
{
903+
return Ok(response);
904+
}
905+
let actor = match require_developer_actor(&req, &ctx.env).await? {
906+
Ok(value) => value,
907+
Err(response) => return Ok(response),
908+
};
909+
store::mark_all_developer_notifications_read(
910+
&db(&ctx)?,
911+
&actor.developer_id,
912+
&actor.account_id,
913+
now(),
914+
)
915+
.await?;
916+
Ok(Response::empty()?.with_status(204))
917+
}
918+
919+
async fn developer_app_history(req: Request, ctx: RouteContext<()>) -> Result<Response> {
920+
let actor = match require_developer_actor(&req, &ctx.env).await? {
921+
Ok(value) => value,
922+
Err(response) => return Ok(response),
923+
};
924+
let bundle_id = param(&ctx, "bundle_id");
925+
let database = db(&ctx)?;
926+
if store::developer_app(&database, &actor.developer_id, bundle_id)
927+
.await?
928+
.is_none()
929+
{
930+
return error("APP_NOT_FOUND", "App not found", 404);
931+
}
932+
json_response(
933+
&json!({"bundle_id":bundle_id,"history":store::app_history(&database,bundle_id).await?}),
934+
200,
935+
)
936+
}
937+
938+
async fn admin_notifications(req: Request, ctx: RouteContext<()>) -> Result<Response> {
939+
let actor = match require_admin(&req, &ctx.env)? {
940+
Ok(value) => value,
941+
Err(response) => return Ok(response),
942+
};
943+
let (limit, offset) = page(&req);
944+
let database = db(&ctx)?;
945+
json_response(
946+
&json!({
947+
"notifications":store::operator_notifications(&database,&actor,limit,offset).await?,
948+
"unread_count":store::operator_unread_count(&database,&actor).await?
949+
}),
950+
200,
951+
)
952+
}
953+
954+
async fn read_admin_notification(req: Request, ctx: RouteContext<()>) -> Result<Response> {
955+
if let Some(response) = rate_limited(
956+
&req,
957+
&ctx.env,
958+
"MUTATION_RATE_LIMITER",
959+
"admin-notification-read",
960+
)
961+
.await?
962+
{
963+
return Ok(response);
964+
}
965+
let actor = match require_admin(&req, &ctx.env)? {
966+
Ok(value) => value,
967+
Err(response) => return Ok(response),
968+
};
969+
let notification_id = param(&ctx, "notification_id");
970+
if !valid_notification_id(notification_id) {
971+
return error("NOTIFICATION_ID_INVALID", "Notification ID is invalid", 422);
972+
}
973+
if !store::mark_operator_notification_read(&db(&ctx)?, notification_id, &actor, now()).await? {
974+
return error("NOTIFICATION_NOT_FOUND", "Notification not found", 404);
975+
}
976+
Ok(Response::empty()?.with_status(204))
977+
}
978+
979+
async fn read_all_admin_notifications(req: Request, ctx: RouteContext<()>) -> Result<Response> {
980+
if let Some(response) = rate_limited(
981+
&req,
982+
&ctx.env,
983+
"MUTATION_RATE_LIMITER",
984+
"admin-notifications-read-all",
985+
)
986+
.await?
987+
{
988+
return Ok(response);
989+
}
990+
let actor = match require_admin(&req, &ctx.env)? {
991+
Ok(value) => value,
992+
Err(response) => return Ok(response),
993+
};
994+
store::mark_all_operator_notifications_read(&db(&ctx)?, &actor, now()).await?;
995+
Ok(Response::empty()?.with_status(204))
996+
}
997+
998+
async fn admin_release_history(req: Request, ctx: RouteContext<()>) -> Result<Response> {
999+
let actor = match require_admin(&req, &ctx.env)? {
1000+
Ok(value) => value,
1001+
Err(response) => return Ok(response),
1002+
};
1003+
let release_id = param(&ctx, "release_id");
1004+
let database = db(&ctx)?;
1005+
if store::release_by_id(&database, release_id).await?.is_none() {
1006+
return error("RELEASE_NOT_FOUND", "Release not found", 404);
1007+
}
1008+
json_response(
1009+
&json!({"admin":actor,"release_id":release_id,"history":store::release_history(&database,release_id).await?}),
1010+
200,
1011+
)
1012+
}
1013+
8311014
async fn admin_release(req: Request, ctx: RouteContext<()>) -> Result<Response> {
8321015
let release_id = param(&ctx, "release_id");
8331016
if auth::reviewer(&req, &ctx.env)? {
@@ -1198,7 +1381,7 @@ async fn invalidate_release(mut req: Request, ctx: RouteContext<()>) -> Result<R
11981381
"release.validation_failed",
11991382
"release",
12001383
release_id,
1201-
json!({"developer_id":value_str(&release,"registered_by"),"asset_id":expected_asset_id,"package_id":value_str(&release,"bundle_id"),"validation_attempt_id":input.validation_attempt_id,"result":"invalid","reason_code":input.error_code}),
1384+
json!({"developer_id":value_str(&release,"registered_by"),"asset_id":expected_asset_id,"package_id":value_str(&release,"bundle_id"),"validation_attempt_id":input.validation_attempt_id,"result":"invalid","reason_code":input.error_code,"summary":input.error_summary}),
12021385
timestamp,
12031386
)
12041387
.await?;
@@ -1382,7 +1565,7 @@ async fn reject_release(mut req: Request, ctx: RouteContext<()>) -> Result<Respo
13821565
"release.reject",
13831566
"release",
13841567
release_id,
1385-
json!({"developer_id":value_str(&release,"registered_by"),"asset_id":release.get("github_asset_id"),"package_id":value_str(&release,"bundle_id"),"result":"rejected","reason_code":input.reason_code}),
1568+
json!({"developer_id":value_str(&release,"registered_by"),"asset_id":release.get("github_asset_id"),"package_id":value_str(&release,"bundle_id"),"result":"rejected","reason_code":input.reason_code,"note":input.note}),
13861569
timestamp,
13871570
)
13881571
.await?;
@@ -1945,9 +2128,35 @@ pub async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
19452128
list_developer_releases,
19462129
)
19472130
.post_async("/v1/developer/apps/:bundle_id/releases", create_release)
2131+
.get_async(
2132+
"/v1/developer/apps/:bundle_id/history",
2133+
developer_app_history,
2134+
)
2135+
.get_async("/v1/developer/notifications", developer_notifications)
2136+
.post_async(
2137+
"/v1/developer/notifications/read-all",
2138+
read_all_developer_notifications,
2139+
)
2140+
.post_async(
2141+
"/v1/developer/notifications/:notification_id/read",
2142+
read_developer_notification,
2143+
)
19482144
.get_async("/v1/admin/releases", admin_releases)
2145+
.get_async("/v1/admin/notifications", admin_notifications)
2146+
.post_async(
2147+
"/v1/admin/notifications/read-all",
2148+
read_all_admin_notifications,
2149+
)
2150+
.post_async(
2151+
"/v1/admin/notifications/:notification_id/read",
2152+
read_admin_notification,
2153+
)
19492154
.post_async("/v1/reviewer/releases/claim", claim_next_release)
19502155
.get_async("/v1/admin/releases/:release_id", admin_release)
2156+
.get_async(
2157+
"/v1/admin/releases/:release_id/history",
2158+
admin_release_history,
2159+
)
19512160
.post_async("/v1/admin/releases/:release_id/validate", validate_release)
19522161
.post_async(
19532162
"/v1/admin/releases/:release_id/validation-failure",
@@ -2250,4 +2459,18 @@ mod tests {
22502459
assert!(production.contains("validation_started_at<?3"));
22512460
assert!(production.contains("\"source\": \"automatic_queue\""));
22522461
}
2462+
2463+
#[test]
2464+
fn notification_and_history_routes_keep_developer_and_operator_scopes_separate() {
2465+
let source = include_str!("lib.rs");
2466+
let production = source.split("#[cfg(test)]").next().unwrap_or_default();
2467+
assert!(valid_notification_id("audit_019f9d57"));
2468+
assert!(!valid_notification_id("../audit"));
2469+
assert!(production.contains("/v1/developer/notifications/:notification_id/read"));
2470+
assert!(production.contains("require_developer_actor(&req, &ctx.env)"));
2471+
assert!(production.contains("/v1/admin/notifications/:notification_id/read"));
2472+
assert!(production.contains("require_admin(&req, &ctx.env)"));
2473+
assert!(production.contains("/v1/developer/apps/:bundle_id/history"));
2474+
assert!(production.contains("/v1/admin/releases/:release_id/history"));
2475+
}
22532476
}

0 commit comments

Comments
 (0)