diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go index e8a7d1c..6d80c0b 100644 --- a/core-backend/a1_a2_routes.go +++ b/core-backend/a1_a2_routes.go @@ -20,6 +20,21 @@ type createContactRequest struct { RelationshipNote string `json:"relationship_note"` } +type updateContactRequest struct { + DisplayName string `json:"display_name" binding:"required"` + ContactUserID *uint `json:"contact_user_id"` + RelationshipNote string `json:"relationship_note"` +} + +func contactJSON(ct Contact) gin.H { + return gin.H{ + "id": ct.ID, + "display_name": ct.DisplayName, + "contact_user_id": ct.ContactUserID, + "relationship_note": ct.RelationshipNote, + } +} + type loginRequest struct { InviteCode string `json:"invite_code" binding:"required"` } @@ -242,12 +257,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{ - "id": contact.ID, - "display_name": contact.DisplayName, - "contact_user_id": contact.ContactUserID, - "relationship_note": contact.RelationshipNote, - }) + c.JSON(http.StatusOK, contactJSON(contact)) }) r.GET("/users/:id/contacts", func(c *gin.Context) { @@ -262,16 +272,47 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts) out := make([]gin.H, 0, len(contacts)) for _, ct := range contacts { - out = append(out, gin.H{ - "id": ct.ID, - "display_name": ct.DisplayName, - "contact_user_id": ct.ContactUserID, - "relationship_note": ct.RelationshipNote, - }) + out = append(out, contactJSON(ct)) } c.JSON(http.StatusOK, gin.H{"contacts": out}) }) + r.PATCH("/users/:id/contacts/:contactId", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !requireSelf(c, db, userID) { + return + } + contactID, ok := parseUintParam(c, "contactId") + if !ok { + return + } + var req updateContactRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + var contact Contact + if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"}) + return + } + if req.ContactUserID != nil && *req.ContactUserID == userID { + c.JSON(http.StatusBadRequest, gin.H{"detail": "cannot set contact_user_id to yourself"}) + return + } + contact.DisplayName = req.DisplayName + contact.ContactUserID = req.ContactUserID + contact.RelationshipNote = req.RelationshipNote + if err := db.Save(&contact).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, contactJSON(contact)) + }) + r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { diff --git a/core-backend/a1_a2_test.go b/core-backend/a1_a2_test.go index 1613699..276d8b9 100644 --- a/core-backend/a1_a2_test.go +++ b/core-backend/a1_a2_test.go @@ -7,6 +7,46 @@ import ( "testing" ) +func TestPatchContactSetsPeerUserID(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "주인") + peerID, _ := mustSignup(t, server.URL, "상대") + + createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "이름만", + }) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("create contact without peer: %d", createResp.StatusCode) + } + var created map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&created) + contactID := uint(created["id"].(float64)) + if created["contact_user_id"] != nil { + t.Fatalf("expected null peer id, got %v", created["contact_user_id"]) + } + + patchResp := patchJSONAuth( + t, + server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10), + ownerToken, + updateContactRequest{ + DisplayName: "상대방", + ContactUserID: &peerID, + }, + ) + if patchResp.StatusCode != http.StatusOK { + t.Fatalf("patch contact: %d", patchResp.StatusCode) + } + var updated map[string]interface{} + json.NewDecoder(patchResp.Body).Decode(&updated) + if uint(updated["contact_user_id"].(float64)) != peerID { + t.Fatalf("expected peer %d, got %v", peerID, updated["contact_user_id"]) + } + if updated["display_name"] != "상대방" { + t.Fatalf("display_name: %v", updated["display_name"]) + } +} + func TestInvitesRequireAdminToken(t *testing.T) { server, _ := setupTestServer(t) resp := postJSON(t, server.URL+"/invites", nil) diff --git a/core-backend/demo.go b/core-backend/demo.go index 7ce0a22..e6278c3 100644 --- a/core-backend/demo.go +++ b/core-backend/demo.go @@ -63,6 +63,17 @@ func registerDemoRoutes(r *gin.Engine) { "demo_invite_code": demoInviteCode, "demo_display_name": "테스터", "hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.", + "pairing_steps": []string{ + "두 명이 같은 DEMO-YKAVU 코드로 각각 가입한다 (시크릿/다른 브라우저).", + "각자 대화 목록의 내 사용자 ID를 복사해 상대에게 알려 준다.", + "연락처에 상대 표시 이름 + 숫자 ID를 넣고 추가한 뒤 「대화」를 누른다.", + "메시지를 보내고, 자율성 L1에서 와카뷰 초안을 한 번 승인·전송해 본다.", + "L0(비서)에서는 초안을 「입력창으로 옮기기」만 되며 — 직접 보낸다.", + }, + "notes": []string{ + "대화는 표시 이름이 아니라 숫자 사용자 ID로 연결됩니다.", + "ID 없는 옛 연락처는 연락처 화면에서 「ID 입력」으로 고치면 됩니다.", + }, }) }) } diff --git a/core-backend/demo_test.go b/core-backend/demo_test.go index b0db64c..6988fa6 100644 --- a/core-backend/demo_test.go +++ b/core-backend/demo_test.go @@ -24,6 +24,10 @@ func TestDemoInviteReusableForMultipleSignups(t *testing.T) { if demo["demo_invite_code"] != demoInviteCode { t.Fatalf("demo code: %v", demo["demo_invite_code"]) } + steps, ok := demo["pairing_steps"].([]any) + if !ok || len(steps) < 3 { + t.Fatalf("expected pairing_steps on /demo, got %#v", demo["pairing_steps"]) + } a := postJSON(t, server.URL+"/auth/signup", signupRequest{ InviteCode: demoInviteCode, diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 4c49d86..1f2bd1d 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -105,6 +105,21 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo return resp } +func patchJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response { + t.Helper() + b, _ := json.Marshal(body) + req, _ := http.NewRequest(http.MethodPatch, url, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("patch %s: %v", url, err) + } + return resp +} + func deleteJSONAuth(t *testing.T, url, token string) *http.Response { t.Helper() req, _ := http.NewRequest(http.MethodDelete, url, nil) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 7116b68..b9538a4 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -148,6 +148,16 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 | | **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 | | **N4-A5** | 프로덕션 web 재빌드 | done | `msn.iykyka.com` health 200 (`3d00b00`) | +| **N4-A6** | ID 없는 연락처 수정(PATCH)·배너 | done | 「ID 입력」으로 peer ID 보강 | + +### Track B — 데모 콘텐츠 (테스터 페어링) + +| ID | 작업 | Status | 완료 조건 | +|----|------|--------|-----------| +| **N4-B1** | `/demo` pairing_steps·notes | done | GET `/demo`에 페어링 단계 | +| **N4-B2** | 테스터 가이드 페어링 문서화 | done | `docs/tester-guide.md` ID 교환 플로우 | +| **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 | +| **N4-B4** | 프로덕션 core+web 재배포 | todo | `/demo` steps + UI 스모크 | ### FCM diff --git a/docs/tester-guide.md b/docs/tester-guide.md index 387a358..82f99b3 100644 --- a/docs/tester-guide.md +++ b/docs/tester-guide.md @@ -1,4 +1,4 @@ -# 와카뷰 테스터 안내 (N3-6) +# 와카뷰 테스터 안내 (N3-6 / Track B) ## 접속 @@ -8,17 +8,28 @@ 같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`). -## 권장 플로우 (5분) +API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포함. -1. 가입 — 초대 코드 `DEMO-YKAVU` 입력 -2. 말투 샘플 온보딩 — 몇 줄 적거나 스킵 -3. 연락처에 상대 유저 등록 → 대화 시작 -4. 메시지 전송 · 분신 초안(L1) 한 번 시도 -5. (선택) 자율성 L0~L2 / 거부권 / 사후알림 함 확인 +## 권장 페어링 플로우 (5~10분) + +두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.** + +1. **가입** — 초대 코드 `DEMO-YKAVU` 입력 (각자 다른 표시 이름 권장) +2. **말투 샘플** — 몇 줄 적거나 스킵 +3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달 +4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」 +5. **메시지** — 사람 모드로 한두 줄 주고받기 +6. **와카뷰 초안** — 메뉴 → 자율성에서 **L1** → 초안이 뜨면 수정/승인하고 보내기 +7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함도 눌러 보세요. + +### ID 없는 옛 연락처 + +이름만 넣고 ID를 비운 연락처는 대화가 안 됩니다. 연락처 화면에서 **「ID 입력」**으로 숫자 ID를 채우면 됩니다 (삭제 후 재추가 불필요). ## 알아둘 점 -- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. (이전에 `no_key`이던 상태는 해소됨) +- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. +- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송. - 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다. - 문제/스크린샷은 Master에게 전달해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart index 0bd8c8e..afd8dc9 100644 --- a/mobile/lib/screens/contacts_screen.dart +++ b/mobile/lib/screens/contacts_screen.dart @@ -123,7 +123,7 @@ class _ContactsScreenState extends State { final session = context.read(); final me = session.user; if (me == null || contact.contactUserId == null) { - setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 삭제 후 숫자 ID와 함께 다시 추가하세요.'); + setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 「ID 입력」으로 숫자 ID를 넣으세요.'); return; } try { @@ -142,6 +142,86 @@ class _ContactsScreenState extends State { } } + Future _editContact(Contact contact) async { + final nameCtrl = TextEditingController(text: contact.displayName); + final peerCtrl = TextEditingController( + text: contact.contactUserId == null ? '' : '${contact.contactUserId}', + ); + final noteCtrl = TextEditingController(text: contact.relationshipNote); + final session = context.read(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + contact.contactUserId == null + ? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.' + : '표시 이름·상대 ID·메모를 고칠 수 있습니다.', + style: Theme.of(ctx).textTheme.bodySmall, + ), + const SizedBox(height: 12), + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: '표시 이름'), + ), + const SizedBox(height: 12), + TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + autofocus: contact.contactUserId == null, + decoration: const InputDecoration( + labelText: '상대 사용자 ID (숫자, 필수)', + helperText: '상대 대화 목록에 보이는 숫자 ID', + ), + ), + const SizedBox(height: 12), + TextField( + controller: noteCtrl, + decoration: const InputDecoration(labelText: '관계 메모 (선택)'), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')), + ], + ), + ); + if (ok != true || !mounted || session.user == null) return; + final name = nameCtrl.text.trim(); + final peer = int.tryParse(peerCtrl.text.trim()); + if (name.isEmpty) return; + if (peer == null) { + setState(() => _error = '상대 사용자 ID(숫자)를 입력해야 합니다.'); + return; + } + if (peer == session.user!.id) { + setState(() => _error = '자기 자신은 연락처에 넣을 수 없습니다.'); + return; + } + try { + final updated = await session.api.updateContact( + userId: session.user!.id, + contactId: contact.id, + displayName: name, + contactUserId: peer, + relationshipNote: noteCtrl.text.trim(), + ); + setState(() { + _contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList(); + _error = null; + }); + } on ApiException catch (e) { + setState(() => _error = '수정 실패 (${e.statusCode}): ${e.body}'); + } + } + Future _delete(Contact c) async { final session = context.read(); if (session.user == null) return; @@ -166,6 +246,7 @@ class _ContactsScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final me = context.watch().user?.id; + final missingIdCount = _contacts.where((c) => c.contactUserId == null).length; return Scaffold( appBar: AppBar( title: const Text('연락처'), @@ -190,6 +271,24 @@ class _ContactsScreenState extends State { padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: MyUserIdChip(userId: me), ), + if (missingIdCount > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Material( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + '사용자 ID가 없는 연락처 $missingIdCount개 — 「ID 입력」으로 숫자 ID를 채우면 대화를 시작할 수 있습니다.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + height: 1.4, + ), + ), + ), + ), + ), if (_error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -247,18 +346,20 @@ class _ContactsScreenState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (c.contactUserId != null) + if (c.contactUserId != null) ...[ FilledButton.tonal( onPressed: () => _startChat(c), child: const Text('대화'), - ) - else - TextButton( - onPressed: () { - setState(() => _error = - '${c.displayName}: 숫자 ID가 없어 대화할 수 없습니다. 삭제 후 ID와 함께 다시 추가하세요.'); - }, - child: const Text('안내'), + ), + IconButton( + tooltip: '수정', + icon: const Icon(Icons.edit_outlined, size: 20), + onPressed: () => _editContact(c), + ), + ] else + FilledButton( + onPressed: () => _editContact(c), + child: const Text('ID 입력'), ), IconButton( tooltip: '삭제', @@ -267,7 +368,13 @@ class _ContactsScreenState extends State { ), ], ), - onTap: c.contactUserId == null ? null : () => _startChat(c), + onTap: () { + if (c.contactUserId == null) { + _editContact(c); + } else { + _startChat(c); + } + }, ), ), const SizedBox(height: 72), diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 4b3b6fb..5904c20 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -266,6 +266,17 @@ class _ConversationListScreenState extends State { height: 1.45, ), ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + await _load(); + }, + icon: const Icon(Icons.contacts_outlined), + label: const Text('연락처 열기'), + ), ], ), ), diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index eb50a6b..ce1519a 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -185,6 +185,14 @@ class _DemoTestPanel extends StatelessWidget { '탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), + const SizedBox(height: 8), + Text( + '페어링: 두 명이 각자 가입 → 내 사용자 ID를 교환 → 연락처에 상대 숫자 ID로 대화 시작', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), ], ), ), diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 0e27a35..6e3f264 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -127,6 +127,21 @@ class ApiClient { return Contact.fromJson(json); } + Future updateContact({ + required int userId, + required int contactId, + required String displayName, + int? contactUserId, + String relationshipNote = '', + }) async { + final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: { + 'display_name': displayName, + if (contactUserId != null) 'contact_user_id': contactUserId, + 'relationship_note': relationshipNote, + }); + return Contact.fromJson(json); + } + Future deleteContact(int userId, int contactId) async { await _json('DELETE', '/users/$userId/contacts/$contactId'); }