feat: finish Track A polish and Track B demo pairing content

Add PATCH contact for missing peer IDs, contacts banner and edit UI,
enrich /demo with pairing steps, and update tester/signup guidance.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-31 04:43:25 +00:00
parent fdd02d8252
commit c7029ab2cd
No known key found for this signature in database
11 changed files with 304 additions and 31 deletions

View File

@ -20,6 +20,21 @@ type createContactRequest struct {
RelationshipNote string `json:"relationship_note"` 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 { type loginRequest struct {
InviteCode string `json:"invite_code" binding:"required"` 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()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, contactJSON(contact))
"id": contact.ID,
"display_name": contact.DisplayName,
"contact_user_id": contact.ContactUserID,
"relationship_note": contact.RelationshipNote,
})
}) })
r.GET("/users/:id/contacts", func(c *gin.Context) { 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) db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts)
out := make([]gin.H, 0, len(contacts)) out := make([]gin.H, 0, len(contacts))
for _, ct := range contacts { for _, ct := range contacts {
out = append(out, gin.H{ out = append(out, contactJSON(ct))
"id": ct.ID,
"display_name": ct.DisplayName,
"contact_user_id": ct.ContactUserID,
"relationship_note": ct.RelationshipNote,
})
} }
c.JSON(http.StatusOK, gin.H{"contacts": out}) 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) { r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id") userID, ok := parseUintParam(c, "id")
if !ok { if !ok {

View File

@ -7,6 +7,46 @@ import (
"testing" "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) { func TestInvitesRequireAdminToken(t *testing.T) {
server, _ := setupTestServer(t) server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/invites", nil) resp := postJSON(t, server.URL+"/invites", nil)

View File

@ -63,6 +63,17 @@ func registerDemoRoutes(r *gin.Engine) {
"demo_invite_code": demoInviteCode, "demo_invite_code": demoInviteCode,
"demo_display_name": "테스터", "demo_display_name": "테스터",
"hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.", "hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.",
"pairing_steps": []string{
"두 명이 같은 DEMO-YKAVU 코드로 각각 가입한다 (시크릿/다른 브라우저).",
"각자 대화 목록의 내 사용자 ID를 복사해 상대에게 알려 준다.",
"연락처에 상대 표시 이름 + 숫자 ID를 넣고 추가한 뒤 「대화」를 누른다.",
"메시지를 보내고, 자율성 L1에서 와카뷰 초안을 한 번 승인·전송해 본다.",
"L0(비서)에서는 초안을 「입력창으로 옮기기」만 되며 — 직접 보낸다.",
},
"notes": []string{
"대화는 표시 이름이 아니라 숫자 사용자 ID로 연결됩니다.",
"ID 없는 옛 연락처는 연락처 화면에서 「ID 입력」으로 고치면 됩니다.",
},
}) })
}) })
} }

View File

@ -24,6 +24,10 @@ func TestDemoInviteReusableForMultipleSignups(t *testing.T) {
if demo["demo_invite_code"] != demoInviteCode { if demo["demo_invite_code"] != demoInviteCode {
t.Fatalf("demo code: %v", demo["demo_invite_code"]) 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{ a := postJSON(t, server.URL+"/auth/signup", signupRequest{
InviteCode: demoInviteCode, InviteCode: demoInviteCode,

View File

@ -105,6 +105,21 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo
return resp 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 { func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
t.Helper() t.Helper()
req, _ := http.NewRequest(http.MethodDelete, url, nil) req, _ := http.NewRequest(http.MethodDelete, url, nil)

View File

@ -148,6 +148,16 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 | | **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 |
| **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 | | **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 |
| **N4-A5** | 프로덕션 web 재빌드 | done | `msn.iykyka.com` health 200 (`3d00b00`) | | **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 ### FCM

View File

@ -1,4 +1,4 @@
# 와카뷰 테스터 안내 (N3-6) # 와카뷰 테스터 안내 (N3-6 / Track B)
## 접속 ## 접속
@ -8,17 +8,28 @@
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`). 같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
## 권장 플로우 (5분) API 메타: `GET https://msn.iykyka.com/demo``pairing_steps` / `notes` 포함.
1. 가입 — 초대 코드 `DEMO-YKAVU` 입력 ## 권장 페어링 플로우 (5~10분)
2. 말투 샘플 온보딩 — 몇 줄 적거나 스킵
3. 연락처에 상대 유저 등록 → 대화 시작 두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
4. 메시지 전송 · 분신 초안(L1) 한 번 시도
5. (선택) 자율성 L0~L2 / 거부권 / 사후알림 함 확인 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)는 아직 플레이스홀더 단계일 수 있습니다. - 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다.
- 문제/스크린샷은 Master에게 전달해 주세요. - 문제/스크린샷은 Master에게 전달해 주세요.
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.

View File

@ -123,7 +123,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
final me = session.user; final me = session.user;
if (me == null || contact.contactUserId == null) { if (me == null || contact.contactUserId == null) {
setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 삭제 후 숫자 ID와 함께 다시 추가하세요.'); setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 「ID 입력」으로 숫자 ID를 넣으세요.');
return; return;
} }
try { try {
@ -142,6 +142,86 @@ class _ContactsScreenState extends State<ContactsScreen> {
} }
} }
Future<void> _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<SessionState>();
final ok = await showDialog<bool>(
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<void> _delete(Contact c) async { Future<void> _delete(Contact c) async {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
if (session.user == null) return; if (session.user == null) return;
@ -166,6 +246,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final me = context.watch<SessionState>().user?.id; final me = context.watch<SessionState>().user?.id;
final missingIdCount = _contacts.where((c) => c.contactUserId == null).length;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('연락처'), title: const Text('연락처'),
@ -190,6 +271,24 @@ class _ContactsScreenState extends State<ContactsScreen> {
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: MyUserIdChip(userId: me), 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) if (_error != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@ -247,18 +346,20 @@ class _ContactsScreenState extends State<ContactsScreen> {
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (c.contactUserId != null) if (c.contactUserId != null) ...[
FilledButton.tonal( FilledButton.tonal(
onPressed: () => _startChat(c), onPressed: () => _startChat(c),
child: const Text('대화'), child: const Text('대화'),
) ),
else IconButton(
TextButton( tooltip: '수정',
onPressed: () { icon: const Icon(Icons.edit_outlined, size: 20),
setState(() => _error = onPressed: () => _editContact(c),
'${c.displayName}: 숫자 ID가 없어 대화할 수 없습니다. 삭제 후 ID와 함께 다시 추가하세요.'); ),
}, ] else
child: const Text('안내'), FilledButton(
onPressed: () => _editContact(c),
child: const Text('ID 입력'),
), ),
IconButton( IconButton(
tooltip: '삭제', tooltip: '삭제',
@ -267,7 +368,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
], ],
), ),
onTap: c.contactUserId == null ? null : () => _startChat(c), onTap: () {
if (c.contactUserId == null) {
_editContact(c);
} else {
_startChat(c);
}
},
), ),
), ),
const SizedBox(height: 72), const SizedBox(height: 72),

View File

@ -266,6 +266,17 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
height: 1.45, 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('연락처 열기'),
),
], ],
), ),
), ),

View File

@ -185,6 +185,14 @@ class _DemoTestPanel extends StatelessWidget {
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능', '탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 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,
),
),
], ],
), ),
), ),

View File

@ -127,6 +127,21 @@ class ApiClient {
return Contact.fromJson(json); return Contact.fromJson(json);
} }
Future<Contact> 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<void> deleteContact(int userId, int contactId) async { Future<void> deleteContact(int userId, int contactId) async {
await _json('DELETE', '/users/$userId/contacts/$contactId'); await _json('DELETE', '/users/$userId/contacts/$contactId');
} }