Create new interfaces for objects transmitted using the REST API

Changes:
- Create new IContact, IAccount, and IConversation interfaces in common/
    - These interfaces represent the serialized versions of the models which are transferred
    - The client models are classes which implement these interfaces
- Create new LookupResult interface for nameserver lookup results
- Create new IConversationMember interface for conversation members
    - The client interface ConversationMember extends this interface to have a Contact field rather than IContact
- Create new ConversationInfos interface for conversation infos
- Create new ContactDetails interface for contact details (used by contacts routes)
- Move request and response body interfaces into common/
- Merge AccountConfig into AccountDetails interface
- Create interfaces for server-only objects:
    - ConversationMemberInfos
    - ConversationRequestMetadata
- Ensure interfaces in jami-signal-interfaces.ts do not contain fields with JamiSwig types
- Rename models/ filenames to camelCase as they are not components
- Rewrite client models to have proper TypeScript accessors and remove unused getters
- Rewrite how client models are initialized from the serialized interface using .fromInterface static methods
- Make client models implement the interfaces in common/ for consistency
- Remove unneeded _next parameter for Express.js route handlers
- Use Partial<T> for all Express.js request body types on server
- Type all Axios response body types with interfaces

GitLab: #92
Change-Id: I4b2c75ac632ec5d9bf12a874a5ba04467c76fa6d
diff --git a/server/src/jamid/conversation-member-infos.ts b/server/src/jamid/conversation-member-infos.ts
new file mode 100644
index 0000000..c2db363
--- /dev/null
+++ b/server/src/jamid/conversation-member-infos.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { ConversationMemberRole } from 'jami-web-common';
+
+export interface ConversationMemberInfos {
+  uri: string;
+  role: ConversationMemberRole;
+  lastDisplayed: string;
+}
diff --git a/server/src/jamid/conversation-request-metadata.ts b/server/src/jamid/conversation-request-metadata.ts
new file mode 100644
index 0000000..d6c187b
--- /dev/null
+++ b/server/src/jamid/conversation-request-metadata.ts
@@ -0,0 +1,22 @@
+/*
+ * 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/>.
+ */
+export interface ConversationRequestMetadata {
+  id: string;
+  from: string;
+  received: string;
+}
diff --git a/server/src/jamid/jami-signal-interfaces.ts b/server/src/jamid/jami-signal-interfaces.ts
index 76025e1..960034b 100644
--- a/server/src/jamid/jami-signal-interfaces.ts
+++ b/server/src/jamid/jami-signal-interfaces.ts
@@ -15,9 +15,9 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { AccountDetails, Message, VolatileDetails } from 'jami-web-common';
+import { AccountDetails, Devices, Message, VolatileDetails } from 'jami-web-common';
 
-import { Blob, StringMap } from './jami-swig.js';
+import { ConversationRequestMetadata } from './conversation-request-metadata.js';
 
 // 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
@@ -55,7 +55,7 @@
 
 export interface KnownDevicesChanged {
   accountId: string;
-  devices: Record<string, string>;
+  devices: Devices;
 }
 
 export interface IncomingAccountMessage {
@@ -75,7 +75,7 @@
   accountId: string;
   conversationId: string;
   from: string;
-  payload: Blob;
+  payload: number[];
   received: number;
 }
 
@@ -94,7 +94,7 @@
 export interface ConversationRequestReceived {
   accountId: string;
   conversationId: string;
-  metadata: StringMap;
+  metadata: ConversationRequestMetadata;
 }
 
 export interface ConversationReady {
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index 30439ef..bb47b4e 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -19,7 +19,11 @@
 
 import {
   AccountDetails,
+  ContactDetails,
+  ConversationInfos,
   ConversationMessage,
+  Devices,
+  LookupResult,
   Message,
   VolatileDetails,
   WebSocketMessage,
@@ -30,6 +34,8 @@
 import { Service } from 'typedi';
 
 import { WebSocketServer } from '../websocket/websocket-server.js';
+import { ConversationMemberInfos } from './conversation-member-infos.js';
+import { ConversationRequestMetadata } from './conversation-request-metadata.js';
 import { JamiSignal } from './jami-signal.js';
 import {
   AccountDetailsChanged,
@@ -53,8 +59,6 @@
 
 const require = createRequire(import.meta.url);
 
-// TODO: Convert Records to interfaces and replace them in common/ (e.g. Contact)
-
 @Service()
 export class Jamid {
   private jamiSwig: JamiSwig;
@@ -100,7 +104,7 @@
       onRegisteredNameFound.next({ accountId, state, address, username });
 
     const onKnownDevicesChanged = new Subject<KnownDevicesChanged>();
-    handlers.KnownDevicesChanged = (accountId: string, devices: Record<string, string>) =>
+    handlers.KnownDevicesChanged = (accountId: string, devices: Devices) =>
       onKnownDevicesChanged.next({ accountId, devices });
 
     const onIncomingAccountMessage = new Subject<IncomingAccountMessage>();
@@ -120,8 +124,11 @@
       onContactRemoved.next({ accountId, contactId, banned });
 
     const onConversationRequestReceived = new Subject<ConversationRequestReceived>();
-    handlers.ConversationRequestReceived = (accountId: string, conversationId: string, metadata: StringMap) =>
-      onConversationRequestReceived.next({ accountId, conversationId, metadata });
+    handlers.ConversationRequestReceived = (
+      accountId: string,
+      conversationId: string,
+      metadata: ConversationRequestMetadata
+    ) => onConversationRequestReceived.next({ accountId, conversationId, metadata });
 
     const onConversationReady = new Subject<ConversationReady>();
     handlers.ConversationReady = (accountId: string, conversationId: string) =>
@@ -232,8 +239,7 @@
     this.jamiSwig.sendAccountTextMessage(accountId, contactId, messageStringMap);
   }
 
-  // TODO: Add interface for returned type
-  async lookupUsername(username: string, accountId?: string) {
+  async lookupUsername(username: string, accountId?: string): Promise<LookupResult> {
     const hasRingNs = this.jamiSwig.lookupName(accountId || '', '', username);
     if (!hasRingNs) {
       throw new Error('Jami does not have NS');
@@ -246,8 +252,7 @@
     );
   }
 
-  // TODO: Add interface for returned type
-  async lookupAddress(address: string, accountId?: string) {
+  async lookupAddress(address: string, accountId?: string): Promise<LookupResult> {
     const hasRingNs = this.jamiSwig.lookupAddress(accountId || '', '', address);
     if (!hasRingNs) {
       throw new Error('Jami does not have NS');
@@ -274,7 +279,7 @@
     );
   }
 
-  getDevices(accountId: string): Record<string, string> {
+  getDevices(accountId: string): Devices {
     return stringMapToRecord(this.jamiSwig.getKnownRingDevices(accountId));
   }
 
@@ -294,14 +299,12 @@
     this.jamiSwig.removeContact(accountId, contactId, true);
   }
 
-  // TODO: Replace Record with interface
-  getContacts(accountId: string): Record<string, string>[] {
-    return vectMapToRecordArray(this.jamiSwig.getContacts(accountId));
+  getContacts(accountId: string): ContactDetails[] {
+    return vectMapToRecordArray(this.jamiSwig.getContacts(accountId)) as unknown as ContactDetails[];
   }
 
-  // TODO: Replace Record with interface
-  getContactDetails(accountId: string, contactId: string): Record<string, string> {
-    return stringMapToRecord(this.jamiSwig.getContactDetails(accountId, contactId));
+  getContactDetails(accountId: string, contactId: string): ContactDetails {
+    return stringMapToRecord(this.jamiSwig.getContactDetails(accountId, contactId)) as unknown as ContactDetails;
   }
 
   getDefaultModeratorUris(accountId: string): string[] {
@@ -320,14 +323,16 @@
     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));
+  getConversationInfos(accountId: string, conversationId: string): ConversationInfos {
+    return stringMapToRecord(
+      this.jamiSwig.conversationInfos(accountId, conversationId)
+    ) as unknown as ConversationInfos;
   }
 
-  // TODO: Replace Record with interface
-  getConversationMembers(accountId: string, conversationId: string): Record<string, string>[] {
-    return vectMapToRecordArray(this.jamiSwig.getConversationMembers(accountId, conversationId));
+  getConversationMembers(accountId: string, conversationId: string): ConversationMemberInfos[] {
+    return vectMapToRecordArray(
+      this.jamiSwig.getConversationMembers(accountId, conversationId)
+    ) as unknown as ConversationMemberInfos[];
   }
 
   async getConversationMessages(accountId: string, conversationId: string, fromMessage?: string): Promise<Message[]> {
diff --git a/server/src/routers/account-router.ts b/server/src/routers/account-router.ts
index 505d1d9..78963e7 100644
--- a/server/src/routers/account-router.ts
+++ b/server/src/routers/account-router.ts
@@ -15,9 +15,10 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { Router } from 'express';
+import { Request, Router } from 'express';
 import asyncHandler from 'express-async-handler';
-import { AccountDetails, HttpStatusCode } from 'jami-web-common';
+import { ParamsDictionary } from 'express-serve-static-core';
+import { AccountDetails, HttpStatusCode, IAccount, IContact } from 'jami-web-common';
 import { Container } from 'typedi';
 
 import { Jamid } from '../jamid/jamid.js';
@@ -38,7 +39,7 @@
 
     // Add usernames for default moderators
     const defaultModeratorUris = jamid.getDefaultModeratorUris(accountId);
-    const namedDefaultModerators = [];
+    const namedDefaultModerators: IContact[] = [];
     for (const defaultModeratorUri of defaultModeratorUris) {
       const { username } = await jamid.lookupAddress(defaultModeratorUri, accountId);
       namedDefaultModerators.push({
@@ -47,17 +48,18 @@
       });
     }
 
-    res.send({
+    const account: IAccount = {
       id: accountId,
       details: jamid.getAccountDetails(accountId),
       volatileDetails: jamid.getVolatileAccountDetails(accountId),
       defaultModerators: namedDefaultModerators,
       devices: jamid.getDevices(accountId),
-    });
+    };
+    res.send(account);
   })
 );
 
-accountRouter.patch('/', (req, res) => {
+accountRouter.patch('/', (req: Request<ParamsDictionary, string, Partial<AccountDetails>>, res) => {
   const accountId = res.locals.accountId;
 
   const currentAccountDetails = jamid.getAccountDetails(accountId);
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index db82fd6..21ffefc 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -19,19 +19,13 @@
 import { Router } from 'express';
 import asyncHandler from 'express-async-handler';
 import { ParamsDictionary, Request } from 'express-serve-static-core';
-import { AccountDetails, HttpStatusCode } from 'jami-web-common';
+import { AccessToken, AccountDetails, HttpStatusCode, UserCredentials } from 'jami-web-common';
 import { Container } from 'typedi';
 
 import { Jamid } from '../jamid/jamid.js';
 import { Accounts } from '../storage/accounts.js';
 import { signJwt } from '../utils/jwt.js';
 
-interface Credentials {
-  username: string;
-  password: string;
-  isJams: boolean;
-}
-
 const jamid = Container.get(Jamid);
 const accounts = Container.get(Accounts);
 
@@ -39,7 +33,7 @@
 
 authRouter.post(
   '/new-account',
-  asyncHandler(async (req: Request<ParamsDictionary, string, Partial<Credentials>>, res, _next) => {
+  asyncHandler(async (req: Request<ParamsDictionary, string, Partial<UserCredentials>>, res) => {
     const { username, password, isJams } = req.body;
     if (username === undefined || password === undefined) {
       res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
@@ -60,7 +54,7 @@
     const hashedPassword = await argon2.hash(password, { type: argon2.argon2id });
 
     const accountDetails: Partial<AccountDetails> = {
-      // TODO: enable encrypted archives
+      // TODO: Enable encrypted archives
       // 'Account.archivePassword': password
     };
     if (isJams) {
@@ -99,37 +93,35 @@
 
 authRouter.post(
   '/login',
-  asyncHandler(
-    async (req: Request<ParamsDictionary, { accessToken: string } | string, Partial<Credentials>>, res, _next) => {
-      const { username, password, isJams } = req.body;
-      if (username === undefined || password === undefined) {
-        res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
-        return;
-      }
-
-      // Check if the account is stored stored on this daemon instance
-      const accountId = jamid.getAccountIdFromUsername(username);
-      if (accountId === undefined) {
-        res.status(HttpStatusCode.NotFound).send('Username not found');
-        return;
-      }
-
-      const hashedPassword = accounts.get(username, isJams);
-      if (hashedPassword === undefined) {
-        res
-          .status(HttpStatusCode.NotFound)
-          .send('Password not found (the account does not have a password set on the server)');
-        return;
-      }
-
-      const isPasswordVerified = await argon2.verify(hashedPassword, password);
-      if (!isPasswordVerified) {
-        res.status(HttpStatusCode.Unauthorized).send('Incorrect password');
-        return;
-      }
-
-      const jwt = await signJwt(accountId);
-      res.send({ accessToken: jwt });
+  asyncHandler(async (req: Request<ParamsDictionary, AccessToken | string, Partial<UserCredentials>>, res) => {
+    const { username, password, isJams } = req.body;
+    if (username === undefined || password === undefined) {
+      res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
+      return;
     }
-  )
+
+    // Check if the account is stored stored on this daemon instance
+    const accountId = jamid.getAccountIdFromUsername(username);
+    if (accountId === undefined) {
+      res.status(HttpStatusCode.NotFound).send('Username not found');
+      return;
+    }
+
+    const hashedPassword = accounts.get(username, isJams);
+    if (hashedPassword === undefined) {
+      res
+        .status(HttpStatusCode.NotFound)
+        .send('Password not found (the account does not have a password set on the server)');
+      return;
+    }
+
+    const isPasswordVerified = await argon2.verify(hashedPassword, password);
+    if (!isPasswordVerified) {
+      res.status(HttpStatusCode.Unauthorized).send('Incorrect password');
+      return;
+    }
+
+    const jwt = await signJwt(accountId);
+    res.send({ accessToken: jwt });
+  })
 );
diff --git a/server/src/routers/conversation-router.ts b/server/src/routers/conversation-router.ts
index b306bf1..bd3a900 100644
--- a/server/src/routers/conversation-router.ts
+++ b/server/src/routers/conversation-router.ts
@@ -18,24 +18,26 @@
 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 {
+  ContactDetails,
+  HttpStatusCode,
+  IConversation,
+  IConversationMember,
+  NewConversationRequestBody,
+  NewMessageRequestBody,
+} 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) {
+async function createConversationResponseObject(
+  accountId: string,
+  accountUri: string,
+  conversationId: string
+): Promise<IConversation | undefined> {
   const infos = jamid.getConversationInfos(accountId, conversationId);
   if (Object.keys(infos).length === 0) {
     return undefined;
@@ -43,7 +45,7 @@
 
   const members = jamid.getConversationMembers(accountId, conversationId);
 
-  const namedMembers = [];
+  const namedMembers: IConversationMember[] = [];
   for (const member of members) {
     // Exclude current user from returned conversation members
     if (member.uri === accountUri) {
@@ -51,7 +53,6 @@
     }
 
     // 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,
@@ -68,8 +69,8 @@
 
   return {
     id: conversationId,
-    messages: messages,
     members: namedMembers,
+    messages: messages,
     infos: infos,
   };
 }
@@ -100,7 +101,7 @@
 
 conversationRouter.post(
   '/',
-  (req: Request<ParamsDictionary, Record<string, string> | string, ConversationMembers>, res) => {
+  (req: Request<ParamsDictionary, ContactDetails | string, Partial<NewConversationRequestBody>>, res) => {
     const { members } = req.body;
     if (members === undefined || members.length !== 1) {
       res.status(HttpStatusCode.BadRequest).send('Missing members or more than one member in body');
@@ -125,7 +126,6 @@
 );
 
 // 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.
@@ -167,7 +167,7 @@
 
 conversationRouter.post(
   '/:conversationId/messages',
-  (req: Request<ParamsDictionary, any, ConversationMessage>, res) => {
+  (req: Request<ParamsDictionary, string, Partial<NewMessageRequestBody>>, res) => {
     const { message } = req.body;
     if (message === undefined) {
       res.status(HttpStatusCode.BadRequest).send('Missing message in body');
diff --git a/server/src/routers/default-moderators-router.ts b/server/src/routers/default-moderators-router.ts
index 06d79ca..90c6387 100644
--- a/server/src/routers/default-moderators-router.ts
+++ b/server/src/routers/default-moderators-router.ts
@@ -17,7 +17,7 @@
  */
 import { Router } from 'express';
 import asyncHandler from 'express-async-handler';
-import { HttpStatusCode } from 'jami-web-common';
+import { HttpStatusCode, IContact } from 'jami-web-common';
 import { Container } from 'typedi';
 
 import { Jamid } from '../jamid/jamid.js';
@@ -36,7 +36,7 @@
 
     // Add usernames for default moderators
     const defaultModeratorUris = jamid.getDefaultModeratorUris(accountId);
-    const namedDefaultModerators = [];
+    const namedDefaultModerators: IContact[] = [];
     for (const defaultModeratorUri of defaultModeratorUris) {
       const { username } = await jamid.lookupAddress(defaultModeratorUri, accountId);
       namedDefaultModerators.push({
diff --git a/server/src/routers/setup-router.ts b/server/src/routers/setup-router.ts
index 3bc3088..d6f4831 100644
--- a/server/src/routers/setup-router.ts
+++ b/server/src/routers/setup-router.ts
@@ -19,7 +19,7 @@
 import { Router } from 'express';
 import asyncHandler from 'express-async-handler';
 import { ParamsDictionary, Request } from 'express-serve-static-core';
-import { HttpStatusCode } from 'jami-web-common';
+import { AccessToken, AdminCredentials, HttpStatusCode } from 'jami-web-common';
 import { Container } from 'typedi';
 
 import { checkAdminSetup } from '../middleware/setup.js';
@@ -30,14 +30,14 @@
 
 export const setupRouter = Router();
 
-setupRouter.get('/check', (_req, res, _next) => {
+setupRouter.get('/check', (_req, res) => {
   const isSetupComplete = adminAccount.get() !== undefined;
   res.send({ isSetupComplete });
 });
 
 setupRouter.post(
   '/admin/create',
-  asyncHandler(async (req: Request<ParamsDictionary, string, { password?: string }>, res, _next) => {
+  asyncHandler(async (req: Request<ParamsDictionary, string, Partial<AdminCredentials>>, res) => {
     const { password } = req.body;
     if (password === undefined) {
       res.status(HttpStatusCode.BadRequest).send('Missing password in body');
@@ -71,28 +71,26 @@
 
 setupRouter.post(
   '/admin/login',
-  asyncHandler(
-    async (req: Request<ParamsDictionary, { accessToken: string } | string, { password: string }>, res, _next) => {
-      const { password } = req.body;
-      if (password === undefined) {
-        res.status(HttpStatusCode.BadRequest).send('Missing password in body');
-        return;
-      }
-
-      const hashedPassword = adminAccount.get();
-      if (hashedPassword === undefined) {
-        res.status(HttpStatusCode.InternalServerError).send('Admin password not found');
-        return;
-      }
-
-      const isPasswordVerified = await argon2.verify(hashedPassword, password);
-      if (!isPasswordVerified) {
-        res.status(HttpStatusCode.Forbidden).send('Incorrect password');
-        return;
-      }
-
-      const jwt = await signJwt('admin');
-      res.send({ accessToken: jwt });
+  asyncHandler(async (req: Request<ParamsDictionary, AccessToken | string, Partial<AdminCredentials>>, res) => {
+    const { password } = req.body;
+    if (password === undefined) {
+      res.status(HttpStatusCode.BadRequest).send('Missing password in body');
+      return;
     }
-  )
+
+    const hashedPassword = adminAccount.get();
+    if (hashedPassword === undefined) {
+      res.status(HttpStatusCode.InternalServerError).send('Admin password not found');
+      return;
+    }
+
+    const isPasswordVerified = await argon2.verify(hashedPassword, password);
+    if (!isPasswordVerified) {
+      res.status(HttpStatusCode.Forbidden).send('Incorrect password');
+      return;
+    }
+
+    const jwt = await signJwt('admin');
+    res.send({ accessToken: jwt });
+  })
 );