Create conversations API routes

Changes:
- Create new conversationRouter with routes for conversations
- Add conversation-related methods to Jamid
- Use Message interface over Record<string, string>
- Add return type annotations for functions in Jamid
- Simplify returned value for account creation/registration

GitLab: #95
Change-Id: Ib0af8b60a92d08ddf4843f874c811e4ead870174
diff --git a/common/src/Conversation.ts b/common/src/Conversation.ts
index 5776c59..a1b03da 100644
--- a/common/src/Conversation.ts
+++ b/common/src/Conversation.ts
@@ -25,7 +25,7 @@
 
 type ConversationInfos = Record<string, unknown>;
 
-export type Message = {
+export interface Message {
   id: string;
   author: string;
   timestamp: string;
@@ -44,7 +44,7 @@
   duration?: string;
   to?: string;
   invited?: string;
-};
+}
 
 type ConversationRequest = PromiseExecutor<Message[]>;
 
diff --git a/server/src/app.ts b/server/src/app.ts
index 4c30a6c..16e9f12 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -25,6 +25,7 @@
 import { accountRouter } from './routers/account-router.js';
 import { authRouter } from './routers/auth-router.js';
 import { contactsRouter } from './routers/contacts-router.js';
+import { conversationRouter } from './routers/conversation-router.js';
 import { nameserverRouter } from './routers/nameserver-router.js';
 
 @Service()
@@ -41,6 +42,9 @@
     app.use('/auth', authRouter);
     app.use('/account', accountRouter);
     app.use('/contacts', contactsRouter);
+    // TODO: Moderator routes: https://git.jami.net/savoirfairelinux/jami-web/-/issues/93
+    app.use('/conversations', conversationRouter);
+    // TODO: Call routes: https://git.jami.net/savoirfairelinux/jami-web/-/issues/107
     app.use('/ns', nameserverRouter);
 
     // Setup 404 error handling
diff --git a/server/src/jamid/jami-signal-interfaces.ts b/server/src/jamid/jami-signal-interfaces.ts
index 189cb53..06dd6ed 100644
--- a/server/src/jamid/jami-signal-interfaces.ts
+++ b/server/src/jamid/jami-signal-interfaces.ts
@@ -15,7 +15,7 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { AccountDetails, VolatileDetails } from 'jami-web-common';
+import { AccountDetails, Message, VolatileDetails } from 'jami-web-common';
 
 // These interfaces are used to hold all the parameters for signal handlers
 // These parameters' names and types can be found in daemon/bin/nodejs/callback.h
@@ -76,11 +76,11 @@
   id: number;
   accountId: string;
   conversationId: string;
-  messages: Record<string, string>[];
+  messages: Message[];
 }
 
 export interface MessageReceived {
   accountId: string;
   conversationId: string;
-  message: Record<string, string>;
+  message: Message;
 }
diff --git a/server/src/jamid/jami-swig.ts b/server/src/jamid/jami-swig.ts
index 1ada8e9..b48db13 100644
--- a/server/src/jamid/jami-swig.ts
+++ b/server/src/jamid/jami-swig.ts
@@ -88,7 +88,6 @@
 
   getConversations(accountId: string): StringVect;
   conversationInfos(accountId: string, conversationId: string): StringMap;
-
   getConversationMembers(accountId: string, conversationId: string): VectMap;
 
   sendMessage(accountId: string, conversationId: string, message: string, replyTo: string): void;
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index ee91ad4..858f4d5 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -15,7 +15,7 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { AccountDetails, VolatileDetails } from 'jami-web-common';
+import { AccountDetails, Message, VolatileDetails } from 'jami-web-common';
 import log from 'loglevel';
 import { filter, firstValueFrom, map, Subject } from 'rxjs';
 import { Service } from 'typedi';
@@ -104,15 +104,11 @@
       onConversationRemoved.next({ accountId, conversationId });
 
     const onConversationLoaded = new Subject<ConversationLoaded>();
-    handlers.ConversationLoaded = (
-      id: number,
-      accountId: string,
-      conversationId: string,
-      messages: Record<string, string>[]
-    ) => onConversationLoaded.next({ id, accountId, conversationId, messages });
+    handlers.ConversationLoaded = (id: number, accountId: string, conversationId: string, messages: Message[]) =>
+      onConversationLoaded.next({ id, accountId, conversationId, messages });
 
     const onMessageReceived = new Subject<MessageReceived>();
-    handlers.MessageReceived = (accountId: string, conversationId: string, message: Record<string, string>) =>
+    handlers.MessageReceived = (accountId: string, conversationId: string, message: Message) =>
       onMessageReceived.next({ accountId, conversationId, message });
 
     // Expose all signals in an events object to allow other handlers to subscribe after jamiSwig.init()
@@ -142,7 +138,7 @@
     // TODO: Bind websocket callbacks for webrtc action on Incoming account message
   }
 
-  stop() {
+  stop(): void {
     this.jamiSwig.fini();
   }
 
@@ -154,7 +150,7 @@
     return stringMapToRecord(this.jamiSwig.getAccountDetails(accountId)) as unknown as AccountDetails;
   }
 
-  setAccountDetails(accountId: string, accountDetails: AccountDetails) {
+  setAccountDetails(accountId: string, accountDetails: AccountDetails): void {
     const accountDetailsStringMap: StringMap = new this.jamiSwig.StringMap();
     for (const [key, value] of Object.entries(accountDetails)) {
       accountDetailsStringMap.set(key, value);
@@ -162,7 +158,7 @@
     this.jamiSwig.setAccountDetails(accountId, accountDetailsStringMap);
   }
 
-  async addAccount(details: Map<string, string | number | boolean>) {
+  async addAccount(details: Map<string, string | number | boolean>): Promise<string> {
     const detailsStringMap: StringMap = new this.jamiSwig.StringMap();
 
     detailsStringMap.set('Account.type', 'RING');
@@ -176,25 +172,27 @@
         filter((value) => value.accountId === accountId),
         // TODO: is it the only state?
         // TODO: Replace with string enum in common/
-        filter(({ state }) => state === 'REGISTERED')
+        filter((value) => value.state === 'REGISTERED'),
+        map((value) => value.accountId)
       )
     );
   }
 
-  removeAccount(accountId: string) {
+  removeAccount(accountId: string): void {
     this.jamiSwig.removeAccount(accountId);
   }
 
-  getAccountList(): string[] {
+  getAccountIds(): string[] {
     return stringVectToArray(this.jamiSwig.getAccountList());
   }
 
-  sendAccountTextMessage(accountId: string, contactId: string, type: string, message: string) {
+  sendAccountTextMessage(accountId: string, contactId: string, type: string, message: string): void {
     const messageStringMap: StringMap = new this.jamiSwig.StringMap();
     messageStringMap.set(type, JSON.stringify(message));
     this.jamiSwig.sendAccountTextMessage(accountId, contactId, messageStringMap);
   }
 
+  // TODO: Add interface for returned type
   async lookupUsername(username: string, accountId?: string) {
     const hasRingNs = this.jamiSwig.lookupName(accountId || '', '', username);
     if (!hasRingNs) {
@@ -208,6 +206,7 @@
     );
   }
 
+  // TODO: Add interface for returned type
   async lookupAddress(address: string, accountId?: string) {
     const hasRingNs = this.jamiSwig.lookupAddress(accountId || '', '', address);
     if (!hasRingNs) {
@@ -221,34 +220,42 @@
     );
   }
 
-  async registerUsername(accountId: string, username: string, password: string) {
+  // TODO: Create enum for state and return that rather than a number
+  async registerUsername(accountId: string, username: string, password: string): Promise<number> {
     const hasRingNs = this.jamiSwig.registerName(accountId, password, username);
     if (!hasRingNs) {
       throw new Error('Jami does not have NS');
     }
-    return firstValueFrom(this.events.onNameRegistrationEnded.pipe(filter((value) => value.accountId === accountId)));
+    return firstValueFrom(
+      this.events.onNameRegistrationEnded.pipe(
+        filter((value) => value.accountId === accountId),
+        map((value) => value.state)
+      )
+    );
   }
 
   getDevices(accountId: string): Record<string, string> {
     return stringMapToRecord(this.jamiSwig.getKnownRingDevices(accountId));
   }
 
-  addContact(accountId: string, contactId: string) {
+  addContact(accountId: string, contactId: string): void {
     this.jamiSwig.addContact(accountId, contactId);
   }
 
-  removeContact(accountId: string, contactId: string) {
+  removeContact(accountId: string, contactId: string): void {
     this.jamiSwig.removeContact(accountId, contactId, false);
   }
 
-  blockContact(accountId: string, contactId: string) {
+  blockContact(accountId: string, contactId: string): void {
     this.jamiSwig.removeContact(accountId, contactId, true);
   }
 
+  // TODO: Replace Record with interface
   getContacts(accountId: string): Record<string, string>[] {
     return vectMapToRecordArray(this.jamiSwig.getContacts(accountId));
   }
 
+  // TODO: Replace Record with interface
   getContactDetails(accountId: string, contactId: string): Record<string, string> {
     return stringMapToRecord(this.jamiSwig.getContactDetails(accountId, contactId));
   }
@@ -257,13 +264,39 @@
     return stringVectToArray(this.jamiSwig.getDefaultModerators(accountId));
   }
 
-  // TODO: Ideally, we would fetch the username directly from Jami instead of
-  // keeping an internal map.
+  getConversationIds(accountId: string): string[] {
+    return stringVectToArray(this.jamiSwig.getConversations(accountId));
+  }
+
+  // TODO: Replace Record with interface
+  getConversationInfos(accountId: string, conversationId: string): Record<string, string> {
+    return stringMapToRecord(this.jamiSwig.conversationInfos(accountId, conversationId));
+  }
+
+  // TODO: Replace Record with interface
+  getConversationMembers(accountId: string, conversationId: string): Record<string, string>[] {
+    return vectMapToRecordArray(this.jamiSwig.getConversationMembers(accountId, conversationId));
+  }
+
+  async getConversationMessages(accountId: string, conversationId: string, fromMessage?: string): Promise<Message[]> {
+    const requestId = this.jamiSwig.loadConversationMessages(accountId, conversationId, fromMessage || '', 32);
+    return firstValueFrom(
+      this.events.onConversationLoaded.pipe(
+        filter((value) => value.id === requestId),
+        map((value) => value.messages)
+      )
+    );
+  }
+
+  sendConversationMessage(accountId: string, conversationId: string, message: string, replyTo?: string): void {
+    this.jamiSwig.sendMessage(accountId, conversationId, message, replyTo || '');
+  }
+
   getAccountIdFromUsername(username: string): string | undefined {
     return this.usernamesToAccountIds.get(username);
   }
 
-  private setupSignalHandlers() {
+  private setupSignalHandlers(): void {
     this.events.onAccountsChanged.subscribe(() => {
       log.debug('Received AccountsChanged');
     });
@@ -273,9 +306,12 @@
     });
 
     this.events.onVolatileDetailsChanged.subscribe(({ accountId, details }) => {
-      log.debug(`Received VolatileDetailsChanged: {"accountId":"${accountId}", ...}`);
-      // Keep map of usernames to account IDs
       const username = details['Account.registeredName'];
+      log.debug(
+        `Received VolatileDetailsChanged: {"accountId":"${accountId}",` +
+          `"details":{"Account.registeredName":"${username}", ...}}`
+      );
+      // Keep map of usernames to account IDs
       if (username) {
         this.usernamesToAccountIds.set(username, accountId);
       }
@@ -309,8 +345,11 @@
       log.debug('Received ConversationRemoved:', JSON.stringify(signal));
     });
 
-    this.events.onConversationLoaded.subscribe((signal) => {
-      log.debug('Received ConversationLoaded:', JSON.stringify(signal));
+    this.events.onConversationLoaded.subscribe(({ id, accountId, conversationId }) => {
+      log.debug(
+        `Received ConversationLoaded: {"id":"${id}","accountId":"${accountId}",` +
+          `"conversationId":"${conversationId}","messages":[...]}`
+      );
     });
 
     this.events.onMessageReceived.subscribe((signal) => {
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index 70eca75..715db0a 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -49,9 +49,9 @@
         issuer: 'urn:example:issuer',
         audience: 'urn:example:audience',
       });
-      res.locals.accountId = payload.id as string;
+      res.locals.accountId = payload.id;
       next();
-    } catch (err) {
+    } catch (e) {
       res.sendStatus(HttpStatusCode.Unauthorized);
     }
   };
diff --git a/server/src/routers/account-router.ts b/server/src/routers/account-router.ts
index 18b1554..59bad05 100644
--- a/server/src/routers/account-router.ts
+++ b/server/src/routers/account-router.ts
@@ -59,12 +59,16 @@
   res.sendStatus(HttpStatusCode.NoContent);
 });
 
-accountRouter.post('/send-account-message', (req: Request<ParamsDictionary, any, SendAccountTextMessageApi>, res) => {
-  const { from, to, type, data } = req.body;
-  if (!from || !to || !type || !data) {
-    res.status(HttpStatusCode.BadRequest).send('Missing arguments in request');
-    return;
+accountRouter.post(
+  '/send-account-message',
+  (req: Request<ParamsDictionary, string, SendAccountTextMessageApi>, res) => {
+    const { from, to, type, data } = req.body;
+    if (!from || !to || !type || !data) {
+      res.status(HttpStatusCode.BadRequest).send('Missing arguments in request');
+      return;
+    }
+
+    jamid.sendAccountTextMessage(from, to, type, data);
+    res.end();
   }
-  jamid.sendAccountTextMessage(from, to, type, data);
-  res.end();
-});
+);
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index d0fc6f0..086f640 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -40,7 +40,7 @@
 
 authRouter.post(
   '/new-account',
-  asyncHandler(async (req: Request<ParamsDictionary, any, Credentials>, res, _next) => {
+  asyncHandler(async (req: Request<ParamsDictionary, string, Credentials>, res, _next) => {
     const { username, password } = req.body;
     if (!username || !password) {
       res.status(HttpStatusCode.BadRequest).send('Missing username or password');
@@ -55,10 +55,10 @@
     // TODO: find a way to store the password directly in Jami
     // Maybe by using the "password" field? But as I tested, it's not
     // returned when getting user infos.
-    const { accountId } = await jamid.addAccount(new Map());
+    const accountId = await jamid.addAccount(new Map());
 
     // TODO: understand why the password arg in this call must be empty
-    const { state } = await jamid.registerUsername(accountId, username, '');
+    const state = await jamid.registerUsername(accountId, username, '');
     if (state !== 0) {
       jamid.removeAccount(accountId);
       if (state === 2) {
@@ -80,7 +80,7 @@
 
 authRouter.post(
   '/login',
-  asyncHandler(async (req: Request<ParamsDictionary, any, Credentials>, res, _next) => {
+  asyncHandler(async (req: Request<ParamsDictionary, { accessToken: string } | string, Credentials>, res, _next) => {
     const { username, password } = req.body;
     if (!username || !password) {
       res.status(HttpStatusCode.BadRequest).send('Missing username or password');
@@ -117,6 +117,6 @@
       .setAudience('urn:example:audience')
       .setExpirationTime('2h')
       .sign(vault.privateKey);
-    res.json({ accessToken: jwt });
+    res.send({ accessToken: jwt });
   })
 );
diff --git a/server/src/routers/conversation-router.ts b/server/src/routers/conversation-router.ts
new file mode 100644
index 0000000..164ea3e
--- /dev/null
+++ b/server/src/routers/conversation-router.ts
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { Request, Router } from 'express';
+import asyncHandler from 'express-async-handler';
+import { ParamsDictionary } from 'express-serve-static-core';
+import { HttpStatusCode } from 'jami-web-common';
+import { Container } from 'typedi';
+
+import { Jamid } from '../jamid/jamid.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+interface ConversationMembers {
+  members: string[];
+}
+
+interface ConversationMessage {
+  message: string;
+}
+
+const jamid = Container.get(Jamid);
+
+// TODO: Create interface for return type in common/ when Records and interfaces are refactored
+async function createConversationResponseObject(accountId: string, accountUri: string, conversationId: string) {
+  const infos = jamid.getConversationInfos(accountId, conversationId);
+  const members = jamid.getConversationMembers(accountId, conversationId);
+
+  const namedMembers = [];
+  for (const member of members) {
+    // Exclude current user from returned conversation members
+    if (member.uri === accountUri) {
+      continue;
+    }
+
+    // Add usernames for conversation members
+    // TODO: Add caching in jamid to avoid too many address -> username lookups?
+    const { username } = await jamid.lookupAddress(member.uri, accountId);
+    namedMembers.push({
+      role: member.role,
+      contact: {
+        uri: member.uri,
+        registeredName: username,
+      },
+    });
+  }
+
+  // TODO: Check if messages actually need to be added to response
+  // (does the client really need it for all endpoints, or just the /conversations/conversationId/messages endpoint?)
+  const messages = await jamid.getConversationMessages(accountId, conversationId);
+
+  return {
+    id: conversationId,
+    messages: messages,
+    members: namedMembers,
+    infos: infos,
+  };
+}
+
+export const conversationRouter = Router();
+
+conversationRouter.use(authenticateToken);
+
+conversationRouter.get(
+  '/',
+  asyncHandler(async (_req, res) => {
+    const accountId = res.locals.accountId;
+
+    // Retrieve the URI of the current account (Account.username actually stores the URI rather than the username)
+    const accountUri = jamid.getAccountDetails(accountId)['Account.username'];
+
+    const conversationIds = jamid.getConversationIds(accountId);
+
+    const conversations = [];
+    for (const conversationId of conversationIds) {
+      const conversation = await createConversationResponseObject(accountId, accountUri, conversationId);
+      conversations.push(conversation);
+    }
+
+    res.send(conversations);
+  })
+);
+
+conversationRouter.post('/', (req: Request<ParamsDictionary, Record<string, string>, ConversationMembers>, res) => {
+  const accountId = res.locals.accountId;
+
+  const { members } = req.body;
+  if (members === undefined || members.length !== 1) {
+    res.sendStatus(HttpStatusCode.BadRequest);
+    return;
+  }
+
+  const contactId = members[0];
+  jamid.addContact(accountId, contactId);
+
+  const contactDetails = jamid.getContactDetails(accountId, contactId);
+  res.send(contactDetails);
+});
+
+// TODO: Check if we actually need this endpoint to return messages.
+// Verify by checking what is truly needed in the client when migrating, to clean up the API.
+// At the moment, /conversations does a lot of work returning all the conversations with the same
+// level of detail as this, and /conversations/messages returns just the messages. Check whether or not
+// this is what we want, and if so, if we can be more economical with client requests.
+conversationRouter.get(
+  '/:conversationId',
+  asyncHandler(async (req, res) => {
+    const accountId = res.locals.accountId;
+    const conversationId = req.params.conversationId;
+
+    // Retrieve the URI of the current account (Account.username actually stores the URI rather than the username)
+    const accountUri = jamid.getAccountDetails(accountId)['Account.username'];
+
+    const conversationIds = jamid.getConversationIds(accountId);
+    if (!conversationIds.includes(conversationId)) {
+      res.sendStatus(HttpStatusCode.NotFound);
+      return;
+    }
+
+    const conversation = await createConversationResponseObject(accountId, accountUri, conversationId);
+    res.send(conversation);
+  })
+);
+
+conversationRouter.get(
+  '/:conversationId/messages',
+  asyncHandler(async (req, res) => {
+    const accountId = res.locals.accountId;
+    const conversationId = req.params.conversationId;
+
+    const conversationIds = jamid.getConversationIds(accountId);
+    if (!conversationIds.includes(conversationId)) {
+      res.sendStatus(HttpStatusCode.NotFound);
+      return;
+    }
+
+    const messages = await jamid.getConversationMessages(accountId, conversationId);
+    res.send(messages);
+  })
+);
+
+conversationRouter.post(
+  '/:conversationId/messages',
+  (req: Request<ParamsDictionary, any, ConversationMessage>, res) => {
+    const { message } = req.body;
+    if (message === undefined) {
+      res.sendStatus(HttpStatusCode.BadRequest);
+      return;
+    }
+
+    jamid.sendConversationMessage(res.locals.accountId, req.params.conversationId, message);
+    res.end();
+  }
+);