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:
parent
fdd02d8252
commit
c7029ab2cd
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 입력」으로 고치면 됩니다.",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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에게 전달해 주세요.
|
||||
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
final session = context.read<SessionState>();
|
||||
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<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 {
|
||||
final session = context.read<SessionState>();
|
||||
if (session.user == null) return;
|
||||
|
|
@ -166,6 +246,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final me = context.watch<SessionState>().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<ContactsScreen> {
|
|||
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<ContactsScreen> {
|
|||
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<ContactsScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
onTap: c.contactUserId == null ? null : () => _startChat(c),
|
||||
onTap: () {
|
||||
if (c.contactUserId == null) {
|
||||
_editContact(c);
|
||||
} else {
|
||||
_startChat(c);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
|
|
|
|||
|
|
@ -266,6 +266,17 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
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('연락처 열기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -127,6 +127,21 @@ class ApiClient {
|
|||
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 {
|
||||
await _json('DELETE', '/users/$userId/contacts/$contactId');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue